mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(gateway): task-suggestion acceptance modes with compact split-button card (#121173)
* feat(gateway): task-suggestion acceptance modes with compact split-button card
- taskSuggestions.accept gains additive mode (worktree default | local | cloud | session) + cloudProfileId; operator.admin unchanged; worktree requests stay {taskId}-only for older gateways
- local reuses the plain-cwd create; cloud mirrors the Control UI create-empty -> sessions.dispatch -> send sequence with pre-claim profile validation and full rollback; session delivers the prompt into the source session (steer single active run, plain turn when idle, ambiguous refused) and can only cancel the chip on failure, never touch the source session
- Control UI card redesigned per owner reference: repo-basename eyebrow, clamped summary, instructions expander, split button with per-profile cloud items or a disabled "No cloud environment configured" hint
- TUI gains the same modes as selector rows with capability gating and double-Enter arming
Release-note context: operators now choose where an accepted follow-up runs — isolated worktree (default), the project checkout, a configured cloud worker, or as the next instruction in the current session — from a compact suggestion card.
* fix(gateway): negotiate task-suggestion acceptance modes and enforce steerable-run invariant
- Gate non-worktree task-suggestion actions on the negotiated Gateway capability.
- Require exactly one steerable run ID before delivering into an active source session.
This commit is contained in:
committed by
GitHub
parent
141304a02e
commit
d3c84674e8
@@ -9378,15 +9378,23 @@ public struct TaskSuggestion: Codable, Sendable {
|
||||
|
||||
public struct TaskSuggestionsAcceptParams: Codable, Sendable {
|
||||
public let taskid: String
|
||||
public let mode: String?
|
||||
public let cloudprofileid: String?
|
||||
|
||||
public init(
|
||||
taskid: String)
|
||||
taskid: String,
|
||||
mode: String? = nil,
|
||||
cloudprofileid: String? = nil)
|
||||
{
|
||||
self.taskid = taskid
|
||||
self.mode = mode
|
||||
self.cloudprofileid = cloudprofileid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case taskid = "taskId"
|
||||
case mode
|
||||
case cloudprofileid = "cloudProfileId"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,22 +3,22 @@ d20eb6a6a77f72566266b88d191b30155757a1cc3d3886d3b6c437176bbac771 module/account
|
||||
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
|
||||
006e581db74e461dcf4d7fd299b09d285f32c91ac12a56de1d21307d272e5a0a module/account-resolution
|
||||
3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives
|
||||
246c3e690220fa805138de4c75d947b82c1ff41a27dd5d64b5260949654295e1 module/agent-harness
|
||||
7598c465d4c8fab2efe6c635b252ff0574325e5665dead98190aab68a27cd1b3 module/agent-harness-runtime
|
||||
6c29184e0f440254aa8c88486ad8b82d9be5228391a3c523cfb7521f44001773 module/agent-harness
|
||||
79ec23a3293c3faaf96f9bb6d5eea289d503fc82a4d8123614d529bdd34a456d module/agent-harness-runtime
|
||||
595ef30046b8ba4bfd427e5a33b65ca28ab1a699ce7e90d6afa2ba2b187e22c0 module/agent-media-payload
|
||||
d575b452a1efad4372f9228c30bbf0aacf602f343ebc95badab9a13b9a2f7db3 module/agent-runtime
|
||||
03861f14e62d022d5f5fac3440e94d223ae2a87c0c99eacdc4762ac41720c937 module/agent-runtime
|
||||
72fa7e17dd1694a24f373dece9d3035c3fc25e1e113954328e16df03d9be7b55 module/agent-scope-runtime
|
||||
8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from
|
||||
ac6c71c3b7c3d1f6d3219d7514a1d1e0c44198358e6e9a4eae06e318d52a8433 module/allowlist-config-edit
|
||||
136ddb70973082ec051eee5a5c2a78da5359f6d0e2dc058be5f7049ad5d2608e module/approval-auth-runtime
|
||||
cdf0f3cab2f9e19bf9a7b9125872b4e9986bcd0530d17a3bdc76ec00aa9d8388 module/approval-client-runtime
|
||||
235abceca3e9392ab39e5e63231337ae9dc35bbb773fb79e0e5a0b56be403455 module/approval-delivery-runtime
|
||||
b49c730d28ede4fe15732a30a5cb6f38fd0ac3246bcec4a0784f0b2f2999ba5a module/approval-gateway-runtime
|
||||
b784be7a3514d186ff62c8d1688fc38d6c0dbad00ecca438e5429d95c0b3b6c9 module/approval-gateway-runtime
|
||||
9750dd4c52ac4b14142be0d39205da33faf584fdb48bb4d2942972e608147ccd module/approval-handler-adapter-runtime
|
||||
f1d44213e7e5d1c8d23499b4f634395a6827ddb390296a9c63f53acb1c108b02 module/approval-handler-runtime
|
||||
fa1d9baff79016a97bdab8f44d6fd6f534fd69d2bf7c3de34a744b278a97a654 module/approval-handler-runtime
|
||||
a0904844a521a3391cbb183e8657b69f7550b6245f88a7bcd7b5c12b2e3fae24 module/approval-native-runtime
|
||||
343b855a38a460a8c3b65b005698ed5c311e9f6b93e7d42a142c5cddb3a08a6f module/approval-reply-runtime
|
||||
fabf4fb018bd3c8a38e6490e28bacae0e1fe9795ea3c334c845c6e23ad95142e module/approval-runtime
|
||||
9657575dca903644b9281b14c6522ecf90e95878368ade8a8ad19850314eaef4 module/approval-runtime
|
||||
01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive
|
||||
d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param
|
||||
1b87d321f91ac51be8010cfd45163e1b1f13e4f68de60539fa8518f54258b4b5 module/channel-actions
|
||||
@@ -26,19 +26,19 @@ d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean
|
||||
c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives
|
||||
76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema
|
||||
63d4f5bc22d6e7779fcfa1f73e70df689189fcc876af26e05a4e3334b95675a9 module/channel-contract
|
||||
317c23fc52ee6981549e0d0ad41475a705b60114dd78f1ec7091a509d6cbbec2 module/channel-core
|
||||
21df5a60780877b5a35388138966b9ecebd3dcdac279117dbc92fd90950e441a module/channel-core
|
||||
f6ddf9086bc224b4b1a516cd7e056467a529d19435930b1c8ddff7052d890ffa module/channel-dm-policy
|
||||
dab6f2650be53f8932a4acf406690731f5cd734805dfe306e34cf3bc1914dd4a module/channel-entry-contract
|
||||
b341b4024b587d0b78efad389b4b688d09e0337d2f43305116b2d6f5c2c4789a module/channel-entry-contract
|
||||
b14ed3e3235fab5725a5eac86bb7910174a21ae1d7f2b4433891867396dcc566 module/channel-feedback
|
||||
628a34efa05f777e1b414428e2992023db40ebd0eb6d48e3450cc06fe3e29935 module/channel-inbound
|
||||
9489208a12adfaef03005900a049847664b003bb4a485662827f9703311e7cd6 module/channel-inbound
|
||||
f58349b93db16be763c7ba4c00cbc13b1b64911c8eefa5a1bfba1e95809e81cf module/channel-inbound-debounce
|
||||
cb7f865c9953b5f0925c918b2bed29fb6629fbd643f4f69bd2bf345970864cbf module/channel-ingress-runtime
|
||||
af775123f5526907732b7bebd6e2258ffe4ec733b2435007361bf7aba5b4eaf4 module/channel-lifecycle
|
||||
0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging
|
||||
7ea11f024148b93698541b6407b270876746b1b579c312a793920293da567e50 module/channel-message
|
||||
44c965b7e16a07d81f94a8447c9386669d11f20ec7efe23a7ec6558287de726d module/channel-outbound
|
||||
3328f581a37b5361dd20c92de9960717a62af49cda86866099e7184c5878435a module/channel-pairing
|
||||
dd59d1fcfbc67c05cc6e61ff7fc4829c71439cfbebd32122a941282dacd4b2f2 module/channel-plugin-common
|
||||
f5793573fe2d002c07c315090b01f7a244d703784c1a444ebd3977e51aaa95e8 module/channel-message
|
||||
3db0b282a857a6f692942fe337d43331f18b51c0b1bcb00c697bef10a96f8b79 module/channel-outbound
|
||||
e38886dbf313f08ba3ae647bd8a8449c3e69cedf8198bb541711dd81631627dc module/channel-pairing
|
||||
bae268499c1cecf06a94bc62e5e33e90c333fc60c85392be3c38b9782f07ef00 module/channel-plugin-common
|
||||
113d135501f2777f308c4c3a59dc29f722f8c385ddfe5628994f14ed37246442 module/channel-policy
|
||||
c3bdfac92ace16eccca1bd44c95a3e73e39d7f996f1714cdfdf102647305df55 module/channel-reply-pipeline
|
||||
482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context
|
||||
@@ -51,8 +51,8 @@ b89224a72be296c82eeb1933b179ec0479627e80e1c61717b6cc7a778dbfa5b2 module/channel
|
||||
67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config
|
||||
1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv
|
||||
ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime
|
||||
fcb5f1e6b515abf23e7efa999e8e73de0b69ec093bab5ae64464ce3c1faf679e module/command-auth
|
||||
0781330f08e2e68f24650355778506dc2011a7b263c14a26573933f19bf4a1c1 module/command-auth-native
|
||||
a17f34e9a20e4314e35df1ceeb95a2ac26721dbd0ea7d161070821a5484501bb module/command-auth
|
||||
55f5fa40cd5350e28c13435ca329580a266a7e266d20db4e3fb88d22b40adca9 module/command-auth-native
|
||||
4db2a3623b116976a2e4cb366163194a8073c8d56a9c74105127bee59c7d07f4 module/command-detection
|
||||
0f6cf0b06cd65f2bfded9d1054873166fa6ad22c71f4f1cc5afb7a49711cc365 module/command-primitives-runtime
|
||||
03eb97e7b47a79ad4b40b37a2bac418c14f85e90969f70fd6fc5c80bb27b8c49 module/command-status
|
||||
@@ -60,23 +60,23 @@ b56349e8d8034152be55161d4bb4c9bf69d0f68f7ea07431f28a4ee7a707d576 module/config-
|
||||
9d2c42377ef981ea6732f96bd3a9c1a16fa2852936516a3fca5adc2b6b0b736e module/config-mutation
|
||||
6c70300fd4bf84808f742687eaad92339fc942abd5cfa2378807a0756decdbd1 module/config-runtime
|
||||
d5a157da395c2b548347b7fae054af0a90afbcae71c7171d0be5c66d92271328 module/conversation-runtime
|
||||
afc89398aa03ff29b8b8f6a07b5a45c6bb3fcf990123d913b1d244a96534ab81 module/core
|
||||
65bd4b4cb27829c7f128aafca177cfeec183a469b9e03100b1114cee5587c6ae module/dedupe-runtime
|
||||
130097615b2034ba3a11a1351b36b33fafe902ba3e3611578e3d5c567eff7390 module/core
|
||||
ab23fff8cbeb96db8b5f6794f8d1141a7233297aeb9734a236c2dfb47cb97e56 module/dedupe-runtime
|
||||
ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap
|
||||
4fe9beab67598950c144d86add5fd3b1ce475aaf37644d2f9798703052d98210 module/diagnostic-runtime
|
||||
734898717c8669f1c3a35dca93db121508368edac7dafcd1ccce71f432680979 module/directory-runtime
|
||||
2ee5dba036aaefad76e440f15f5f5dd4fa329e19d3b5f63eb2797da84697cb19 module/discord
|
||||
f55c941e465fc7dcd629005274f747ed52600b537e7ce0e87882697b8e2858c0 module/discord
|
||||
2b01f2e5a52713158665372b358fb6059903f68b6efbedff700741d9feca7696 module/error-runtime
|
||||
03eeafd10471b94a2651e0d42380a32f5ffd4f33c47e4b0161205093163e9407 module/extension-shared
|
||||
dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime
|
||||
64c88d5090e478fe9a16fb6845f8bef2b804d4590f812b6f2cf1ec2c3c857306 module/gateway-runtime
|
||||
b5dfcf2977a334afb5a138ba9af196902ae8baf54a843c57496ed826e410178b module/gateway-runtime
|
||||
575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access
|
||||
f3595668fc4c20b2b34df67d3fa4d5432c16fa3be5fec9e63d8f14512ab6732f module/health
|
||||
60d126e420e212415f25b6e90c2aca7513a1275e14b2bad27b06aeb6ab12eaa5 module/hook-runtime
|
||||
da9d83537008db2c9339f3a4235ed2b7ee3ac986cacdc8bf8fdfaf1a025bd6a9 module/inbound-envelope
|
||||
4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery
|
||||
b2fb4eba715bbe12c02797e9da4713f6a114b85d6a683a141e342bf65f54f34c module/inbound-reply-dispatch
|
||||
e5fdb21e7d557fb6830f9b7e9860bdd9b11f9ceb8c34e59b85e12a5ec5b022a3 module/infra-runtime
|
||||
6faeea0ea0ca0dd41839452c1dc98f2361319fbef85830749c15a446251e40c0 module/inbound-reply-dispatch
|
||||
539921d6c5596381beb17505394c67be44edb4a5ac533d555f07c43c4c991e8f module/infra-runtime
|
||||
ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once
|
||||
31449afd7ea7f0c8dc7dee439a3693c3d1092af67c6bed67dd34e7b7b50bc74e module/interactive-runtime
|
||||
408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store
|
||||
@@ -89,28 +89,28 @@ f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-m
|
||||
6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store
|
||||
b3fdb9f96d2724d5824063f434f3e482b605e4c06cad9770a94c78079dc36454 module/media-understanding
|
||||
544a6d47a391e64574b146f649e8065b70a57955088b85cbdbdacb872889475a module/media-understanding-runtime
|
||||
87d881f7d074bd0b47073f5d8b4e3f52d2ac2e2ecdcf13b3173bd1d74bb7e161 module/meeting-runtime
|
||||
a683db04e6ef6dedc6c50b6a1071cd881a92f1b5bdf236e5a3bbbde65227ce80 module/meeting-runtime
|
||||
a6aac1a3f85d3ee7dc9fbc7a1c6ef7dd00a9c2d3bdb6dc8d57a9704a821f806d module/memory-core-host-engine-foundation
|
||||
e54a4fa88113a3ea31f52cfa26723705496214f8bf935c6f3771e6626af2c128 module/memory-host-core
|
||||
a7295add0e30a7d9b322b59ce4c78579c3b81fe362ab6666f3323e1ac73e1b22 module/memory-host-core
|
||||
1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets
|
||||
8647dda41c0c3ab67191c397aa405de914fd8f277f55f550a307524e3c20d98d module/model-session-runtime
|
||||
866a46928c9cb126eeccb353d131f779f99b635ab88de12d4d1cb86112c30662 module/models-provider-runtime
|
||||
99e43d2c7db0bcf2648c156644175d1c8238650db4734221d3d56b54da45da0b module/models-provider-runtime
|
||||
504c61546d566814cda2d8126b458efc9ad70b75f1406ab9d6635ac10225200c module/native-command-config-runtime
|
||||
d808e6681668e70b2dbb2ac590b455072f98d54970593e25ef6ad5c2322d3a51 module/native-command-registry
|
||||
ca6ee4fa75f976d590210b9ff6dc66374bb829a05d4f70972eef9137f5548b88 module/param-readers
|
||||
ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe
|
||||
3c73ab232d86b49ebb6e5f302da2a2a4b5c7f62bff5e4cc0d3505edc5ba4f5cd module/plugin-config-runtime
|
||||
7de93d534a88300d5c701428b22d488e92e60d2282a2726e93b3f95e7b6b6a3e module/plugin-entry
|
||||
03839a6d77ba6a314510cf18b4cf3d3a01ffc1ef8fa52d625a0a56addc4cc26e module/plugin-runtime
|
||||
7a9e4a6a956776c40b4ea27e34834241c8181d2a1875532e2b178c823fac19f1 module/provider-auth
|
||||
633ec2b88f7da1e5e6f9847d943ee04ad30510470cfd824643b62aab6179c7a5 module/provider-catalog-runtime
|
||||
9c97ce162fb9c3f9039ca05d587da55243bcb64ebeb0fa224c0927bfbba6ad6b module/plugin-entry
|
||||
160fa11a242c65bb95fc498eb540791f34fe369774fa35924c719a090add2f80 module/plugin-runtime
|
||||
554e8adfe5e3b935470940e7dd15a6e178d56ff95441e6a6231f7643884dc6d9 module/provider-auth
|
||||
348867eefd7442d9cbf79aef81e2fc08e4986c871fca1f6cb8025d68d37952d4 module/provider-catalog-runtime
|
||||
8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture
|
||||
4949fe3958d3b92b8d2e13ec0af9e65c88e1c7d3e39e70c306fd78c93b094e87 module/question-gateway-runtime
|
||||
8f01e5ed6bccbb1e13cda4dd0a0b18f39af08ad36d7f0080b12217565f2e576c module/question-gateway-runtime
|
||||
a479cd5c96a34c6f0a2ed4d4239aad63b56cb0e47a970848a141ad7fb67b9e11 module/reply-chunking
|
||||
113538e1916037a0184103122fd1db73170686e0583acbcae6c2c1887cce95d5 module/reply-dispatch-runtime
|
||||
c0e97a2e6490cd87e9777e1c197a12da368e2cfaac713f0264bdf5c93ee0f67c module/reply-dispatch-runtime
|
||||
73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history
|
||||
d3bf7e4a7fcaebc1cf3173f4fbb60203e6ec46257cddcca7096800df946708fc module/reply-payload
|
||||
df1a7c6ab3ee5c487d5388871481fe499a044fa61d7bf7c8a95edbb4747293d9 module/reply-runtime
|
||||
c8bb40393858a20698255aa82a05f89234320eb8f2bda3987b6e7f2d574f3c67 module/reply-runtime
|
||||
aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk
|
||||
02c0e5cae6772159a1c5ff43209de542ac7976dc30541d406f9dc6c37141a69d module/routing
|
||||
7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command
|
||||
@@ -118,20 +118,20 @@ bd15eb9689fd7070dc942cb4bd0db6c2c1d4fb1a971ce9d0a6349a5679309535 module/runtime
|
||||
ccb6aad96b4ea156738f45bac04b6eda5826e17b36eb8b5e26567f400b3be217 module/runtime-config-snapshot
|
||||
2e3c692a9f911ab227e34a9a3870f8e139f90ceaff631301b55ce712dec53736 module/runtime-env
|
||||
7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy
|
||||
0e1e9b7ffec401e73272fcf009472ace65f158cbaafc66c5a40f4c9cec8688d8 module/runtime-store
|
||||
0843d2663cca6758528b26e66cc650cdf9882c4a41e8733ee593914e61a5f2f7 module/runtime-store
|
||||
d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file
|
||||
8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input
|
||||
1806dba733bb6d88b4e997d3dc989761f0d7dde7638d96e1218705fd27f0921b module/secret-input-runtime
|
||||
8e0e6d67db89eeee760a33ac984e7141bde171080365dddf534858b6abb3d56d module/secret-ref-runtime
|
||||
c810981c42d32923e84c42d20137c9dc393aabc0ea079786e01d19bd59fa5277 module/security-runtime
|
||||
94ad1c93b1d65d10cfbd3692423688d67e5b451bc6090190da0eb6a21d510ddb module/session-catalog
|
||||
11788a4fc8f7f6a5956f1968db768b6e85bf9c35246cf7069484921ad46874cf module/session-discussion
|
||||
dce5dfdf49e4355f1cf9432fc3faa3a95756812839a83c12b061525be4b7a286 module/session-catalog
|
||||
e391a41f71a490877f000fdc67386086de79bcb3f7a2382ec44ee0b1edb0a89a module/session-discussion
|
||||
fd2dcb08a59df7bb68c8f36f272d134f6904d12b9e192bc8d7a4858d3039b595 module/session-store-runtime
|
||||
73f4a776c027d974f010250360822694486a8033bf1b51e96a0ff2440b05dbfe module/setup
|
||||
f1ec91331ad72c3fd9edb2cb11b4c6a35acd55ef3e4f541c5907149eed21b15f module/setup-runtime
|
||||
44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools
|
||||
00e8794c6e7aabbdeec14189d885d4021f5118fcb08f7e34b85fafc473e62bed module/skill-commands-runtime
|
||||
c249b3354e9345e0300cfd8a64d7ba5e9d87da6729f04875dc78a837332a6402 module/speech-settings
|
||||
0b8fd0e49dc31c1f8be63e215ff5904f01adf523d88897a32253e9113b72058d module/speech-settings
|
||||
f01b661de86de0d0b1d3bff395330092f3fe1114f4516968ca5fca79cf7eac50 module/ssrf-policy
|
||||
0296f2c837f8116aa3a7e8b02d3de8b756d44e0a004842dbd24a5aa627f74a54 module/ssrf-runtime
|
||||
eace34246d7a827b00a67bde258f8401bab35f527dcad29e6f982e4ae39cf013 module/state-paths
|
||||
@@ -141,11 +141,11 @@ c6ea76a9fa7f56771cbfc54617aa9ceec3d87241bab38a094622ea9351f3cc87 module/telegra
|
||||
aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path
|
||||
87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking
|
||||
2ce8b180da90b5b1665bc65dd9cae9af7eb4e0988cc2a64685e8e6f9cb9a87c3 module/text-runtime
|
||||
1fb03e380ca0c99eb825fa96e1a4c8b6b01a3aa3243d8518c0890c04fec87c0b module/tool-plugin
|
||||
93829a873d39e2d75e33117c5622038209ea0837589ade208599b7d34d463c5a module/tool-plugin
|
||||
dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results
|
||||
3c97f778d2844ba1bfd3e77fbccd3bfed94bc102053c091b00a0d3d2aaff6a99 module/tool-send
|
||||
cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media
|
||||
eb52639840e98b894c310775e3559027a2eaf48c29f8b6ffcd346ae48114c088 module/webhook-ingress
|
||||
1a693b9aec4fb07268d32ab35b719093254cb4de8b544d6b7d0fff2290037db5 module/webhook-ingress
|
||||
216e54c25ec0985fc483899d4de3d2582053a4fc3a2d1c98a8b5e7cdd2f136e1 module/webhook-request-guards
|
||||
de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html
|
||||
9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod
|
||||
|
||||
@@ -47,7 +47,7 @@ Local onboarding defaults new local configs to `tools.profile: "coding"` when un
|
||||
| `group:openclaw` | All built-in tools above except `read`/`write`/`edit`/`apply_patch`/`exec`/`process`/`canvas` (excludes plugin tools) |
|
||||
| `group:plugins` | Tools owned by loaded plugins, including configured MCP servers exposed through `bundle-mcp` |
|
||||
|
||||
`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The suggestion's project directory must be a git checkout; invalid suggestions, including a non-git directory or blank prompt, are rejected when the tool records them. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting either creates a fresh managed-worktree session and sends the full prompt there while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`.
|
||||
`spawn_task` lets a coding agent propose confirmed follow-up work without starting it. The suggestion's project directory must be a git checkout; invalid suggestions, including a non-git directory or blank prompt, are rejected when the tool records them. The Control UI shows the title and summary as an actionable chip; a Gateway-backed TUI shows an equivalent interactive prompt. Accepting a suggestion can start it in a fresh managed worktree (the default), start it locally in a new session in the suggested checkout, send it to a cloud worker profile when one is configured, or deliver it into the source session. OpenClaw sends the full prompt to the selected destination while the current turn continues. `dismiss_task` withdraws a still-pending suggestion by the ephemeral `task_id` returned from `spawn_task`.
|
||||
|
||||
The tools are offered only when the initiating operator surface can receive and action Gateway task-suggestion events. Channel sessions and local/embedded TUI sessions do not receive them; channel transports need a portable typed task action before they can safely expose this flow. Suggestions are process-local and disappear when the Gateway restarts. Both tools remain in the `coding` profile and `group:sessions`, so normal `tools.allow` and `tools.deny` policy configures them automatically when the surface supports them.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const GATEWAY_SERVER_CAPS = {
|
||||
BOARD_WIDGET_PUT_CANVAS_DOC: "board-widget-put-canvas-doc",
|
||||
CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract",
|
||||
SYSTEM_AGENT_SETUP_MODEL_REF: "openclaw-setup-model-ref",
|
||||
TASK_SUGGESTIONS_ACCEPT_MODES: "taskSuggestions.acceptModes",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,15 @@ const TaskTldrSchema = Type.String({ minLength: 1, maxLength: 1_024, pattern: "\
|
||||
const TaskCwdSchema = Type.String({ minLength: 1, maxLength: 4_096 });
|
||||
const TaskSessionKeySchema = Type.String({ minLength: 1, maxLength: 512 });
|
||||
const TaskAgentIdSchema = Type.String({ minLength: 1, maxLength: 128 });
|
||||
const TaskSuggestionAcceptanceModeSchema = Type.Enum(
|
||||
{
|
||||
WORKTREE: "worktree",
|
||||
LOCAL: "local",
|
||||
CLOUD: "cloud",
|
||||
SESSION: "session",
|
||||
} as const,
|
||||
{ type: "string" },
|
||||
);
|
||||
|
||||
/** One model-proposed follow-up task waiting for operator action. */
|
||||
export const TaskSuggestionSchema = closedObject({
|
||||
@@ -54,8 +63,12 @@ export const TaskSuggestionResolutionSchema = Type.Union([
|
||||
Type.Literal("expired"),
|
||||
]);
|
||||
|
||||
/** Atomically claims a pending suggestion and starts its server-owned worktree session. */
|
||||
export const TaskSuggestionsAcceptParamsSchema = closedObject({ taskId: TaskIdSchema });
|
||||
/** Atomically claims a pending suggestion and starts it in the requested execution mode. */
|
||||
export const TaskSuggestionsAcceptParamsSchema = closedObject({
|
||||
taskId: TaskIdSchema,
|
||||
mode: Type.Optional(TaskSuggestionAcceptanceModeSchema),
|
||||
cloudProfileId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
||||
});
|
||||
|
||||
export const TaskSuggestionsAcceptResultSchema = closedObject({
|
||||
taskId: TaskIdSchema,
|
||||
|
||||
@@ -114,7 +114,7 @@ function listWorkerEnvironments(context: GatewayRequestContext): WorkerEnvironme
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function listWorkerProfiles(context: GatewayRequestContext) {
|
||||
export function listWorkerProfiles(context: GatewayRequestContext) {
|
||||
if (!context.workerEnvironmentService || !context.workerPlacementDispatchService) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GATEWAY_CLIENT_CAPS } from "../../../packages/gateway-protocol/src/client-info.js";
|
||||
import { managedWorktrees } from "../../agents/worktrees/service.js";
|
||||
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
|
||||
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
abandonTaskSuggestionAcceptance,
|
||||
beginTaskSuggestionAcceptance,
|
||||
@@ -7,8 +12,13 @@ import {
|
||||
} from "../task-suggestion-registry.js";
|
||||
import { sessionCreateHandlers } from "./sessions-create.js";
|
||||
import { sessionDeleteHandlers } from "./sessions-delete.js";
|
||||
import { sessionDispatchHandlers } from "./sessions-dispatch.js";
|
||||
import { taskSuggestionsHandlers } from "./task-suggestions.js";
|
||||
import type { RespondFn } from "./types.js";
|
||||
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ handleChatSend: vi.fn() }));
|
||||
|
||||
vi.mock("./chat-send-handler.js", () => ({ handleChatSend: mocks.handleChatSend }));
|
||||
|
||||
type Method =
|
||||
| "taskSuggestions.list"
|
||||
@@ -17,16 +27,28 @@ type Method =
|
||||
| "taskSuggestions.dismiss";
|
||||
|
||||
const GIT_CWD = process.cwd();
|
||||
const SOURCE_SESSION_KEY = "agent:main:source";
|
||||
|
||||
async function call(method: Method, params: Record<string, unknown>, broadcast = vi.fn()) {
|
||||
async function call(
|
||||
method: Method,
|
||||
params: Record<string, unknown>,
|
||||
broadcast = vi.fn(),
|
||||
overrides: {
|
||||
client?: GatewayClient | null;
|
||||
context?: Partial<GatewayRequestContext>;
|
||||
} = {},
|
||||
) {
|
||||
const calls: Parameters<RespondFn>[] = [];
|
||||
const respond: RespondFn = (...args) => {
|
||||
calls.push(args);
|
||||
};
|
||||
await taskSuggestionsHandlers[method]?.({
|
||||
req: { type: "req", id: "request-1", method, params },
|
||||
params,
|
||||
respond,
|
||||
context: { broadcast, getRuntimeConfig: () => ({}) },
|
||||
client: overrides.client ?? null,
|
||||
isWebchatConnect: () => true,
|
||||
context: { broadcast, getRuntimeConfig: () => ({}), ...overrides.context },
|
||||
} as never);
|
||||
return { response: calls[0], broadcast };
|
||||
}
|
||||
@@ -47,12 +69,59 @@ async function dismissPendingTaskSuggestions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(dismissPendingTaskSuggestions);
|
||||
beforeEach(async () => {
|
||||
await dismissPendingTaskSuggestions();
|
||||
mocks.handleChatSend.mockReset();
|
||||
mocks.handleChatSend.mockImplementation(async ({ respond }: { respond: RespondFn }) => {
|
||||
respond(true, { runId: "suggested-task-run", status: "started" }, undefined);
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await dismissPendingTaskSuggestions();
|
||||
vi.restoreAllMocks();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
});
|
||||
|
||||
function operatorClient(): GatewayClient {
|
||||
return {
|
||||
connect: {
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: {
|
||||
id: "openclaw-control-ui",
|
||||
version: "test",
|
||||
platform: "test",
|
||||
mode: "webchat",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
caps: [GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function configuredCloudContext(
|
||||
profiles: Record<string, { provider: string }> = { primary: { provider: "test" } },
|
||||
): Partial<GatewayRequestContext> {
|
||||
return {
|
||||
workerEnvironmentService: {} as never,
|
||||
workerPlacementDispatchService: {} as never,
|
||||
getRuntimeConfig: () => ({ cloudWorkers: { profiles } }),
|
||||
};
|
||||
}
|
||||
|
||||
async function createSourceSuggestion() {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Fix the source session",
|
||||
prompt: "Apply the focused fix in this session.",
|
||||
tldr: "The current session already owns the relevant context.",
|
||||
cwd: GIT_CWD,
|
||||
sessionKey: SOURCE_SESSION_KEY,
|
||||
agentId: "main",
|
||||
});
|
||||
return (requirePayload(created) as { taskId: string }).taskId;
|
||||
}
|
||||
|
||||
describe("task suggestion gateway methods", () => {
|
||||
it("creates, lists, and resolves an ephemeral suggestion", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
@@ -258,6 +327,366 @@ describe("task suggestion gateway methods", () => {
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts local acceptance in the suggestion cwd without a worktree", async () => {
|
||||
const taskId = await createSourceSuggestion();
|
||||
const createSession = vi
|
||||
.spyOn(sessionCreateHandlers, "sessions.create")
|
||||
.mockImplementation(async ({ params, respond }) => {
|
||||
expect(params).toMatchObject({
|
||||
agentId: "main",
|
||||
parentSessionKey: SOURCE_SESSION_KEY,
|
||||
label: "Fix the source session",
|
||||
task: "Apply the focused fix in this session.",
|
||||
cwd: GIT_CWD,
|
||||
});
|
||||
expect(params).not.toHaveProperty("worktree");
|
||||
respond(true, { key: (params as { key: string }).key, runStarted: true }, undefined);
|
||||
});
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId, mode: "local" });
|
||||
|
||||
expect(accepted.response?.[0]).toBe(true);
|
||||
expect(createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rolls back a local session when its initial task does not start", async () => {
|
||||
const taskId = await createSourceSuggestion();
|
||||
let sessionKey = "";
|
||||
vi.spyOn(sessionCreateHandlers, "sessions.create").mockImplementation(
|
||||
async ({ params, respond }) => {
|
||||
sessionKey = (params as { key: string }).key;
|
||||
expect(params).not.toHaveProperty("worktree");
|
||||
expect(params).toMatchObject({ cwd: GIT_CWD });
|
||||
respond(true, { key: sessionKey, runStarted: false }, undefined);
|
||||
},
|
||||
);
|
||||
const deleteSession = vi
|
||||
.spyOn(sessionDeleteHandlers, "sessions.delete")
|
||||
.mockImplementation(async ({ respond }) => {
|
||||
respond(true, { deleted: true }, undefined);
|
||||
});
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId, mode: "local" });
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(deleteSession).toHaveBeenCalledTimes(1);
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
});
|
||||
|
||||
it("creates, dispatches, then sends a cloud acceptance", async () => {
|
||||
const taskId = await createSourceSuggestion();
|
||||
const sequence: string[] = [];
|
||||
let sessionKey = "";
|
||||
vi.spyOn(sessionCreateHandlers, "sessions.create").mockImplementation(
|
||||
async ({ params, respond }) => {
|
||||
sequence.push("create");
|
||||
sessionKey = (params as { key: string }).key;
|
||||
expect(params).toEqual({
|
||||
key: sessionKey,
|
||||
agentId: "main",
|
||||
parentSessionKey: SOURCE_SESSION_KEY,
|
||||
label: "Fix the source session",
|
||||
worktree: true,
|
||||
cwd: GIT_CWD,
|
||||
});
|
||||
respond(true, { key: sessionKey, runStarted: false }, undefined);
|
||||
},
|
||||
);
|
||||
vi.spyOn(sessionDispatchHandlers, "sessions.dispatch").mockImplementation(
|
||||
async ({ params, respond }) => {
|
||||
sequence.push("dispatch");
|
||||
expect(params).toEqual({ key: sessionKey, agentId: "main", profileId: "primary" });
|
||||
respond(true, { ok: true, key: sessionKey }, undefined);
|
||||
},
|
||||
);
|
||||
mocks.handleChatSend.mockImplementationOnce(async ({ params, respond }) => {
|
||||
sequence.push("send");
|
||||
expect(params).toEqual({
|
||||
sessionKey,
|
||||
agentId: "main",
|
||||
message: "Apply the focused fix in this session.",
|
||||
idempotencyKey: `task-suggestion:${taskId}`,
|
||||
});
|
||||
respond(true, { runId: "cloud-run", status: "started" }, undefined);
|
||||
});
|
||||
|
||||
const accepted = await call(
|
||||
"taskSuggestions.accept",
|
||||
{ taskId, mode: "cloud", cloudProfileId: "primary" },
|
||||
vi.fn(),
|
||||
{ client: operatorClient(), context: configuredCloudContext() },
|
||||
);
|
||||
|
||||
expect(accepted.response?.[1]).toEqual({ taskId, key: sessionKey });
|
||||
expect(sequence).toEqual(["create", "dispatch", "send"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "no configured profiles",
|
||||
context: configuredCloudContext({}),
|
||||
cloudProfileId: "primary",
|
||||
message: "no cloud worker profiles configured",
|
||||
},
|
||||
{
|
||||
label: "an unknown profile",
|
||||
context: configuredCloudContext(),
|
||||
cloudProfileId: "missing",
|
||||
message: "unknown cloud worker profile: missing",
|
||||
},
|
||||
{
|
||||
label: "a missing profile id",
|
||||
context: configuredCloudContext(),
|
||||
cloudProfileId: undefined,
|
||||
message: "cloudProfileId is required for cloud mode",
|
||||
},
|
||||
])("rejects cloud acceptance with $label before handler calls", async (testCase) => {
|
||||
const taskId = await createSourceSuggestion();
|
||||
const createSession = vi.spyOn(sessionCreateHandlers, "sessions.create");
|
||||
const dispatchSession = vi.spyOn(sessionDispatchHandlers, "sessions.dispatch");
|
||||
|
||||
const accepted = await call(
|
||||
"taskSuggestions.accept",
|
||||
{
|
||||
taskId,
|
||||
mode: "cloud",
|
||||
...(testCase.cloudProfileId ? { cloudProfileId: testCase.cloudProfileId } : {}),
|
||||
},
|
||||
vi.fn(),
|
||||
{ context: testCase.context },
|
||||
);
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: testCase.message,
|
||||
});
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(dispatchSession).not.toHaveBeenCalled();
|
||||
expect(mocks.handleChatSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fully rolls back a cloud draft when dispatch fails", async () => {
|
||||
const taskId = await createSourceSuggestion();
|
||||
let sessionKey = "";
|
||||
vi.spyOn(sessionCreateHandlers, "sessions.create").mockImplementation(
|
||||
async ({ params, respond }) => {
|
||||
sessionKey = (params as { key: string }).key;
|
||||
respond(true, { key: sessionKey }, undefined);
|
||||
},
|
||||
);
|
||||
vi.spyOn(sessionDispatchHandlers, "sessions.dispatch").mockImplementation(
|
||||
async ({ respond }) => {
|
||||
respond(false, undefined, { code: "UNAVAILABLE", message: "dispatch unavailable" });
|
||||
},
|
||||
);
|
||||
const deleteSession = vi
|
||||
.spyOn(sessionDeleteHandlers, "sessions.delete")
|
||||
.mockImplementation(async ({ params, respond }) => {
|
||||
expect(params).toMatchObject({ key: sessionKey, agentId: "main" });
|
||||
respond(true, { deleted: true }, undefined);
|
||||
});
|
||||
|
||||
const accepted = await call(
|
||||
"taskSuggestions.accept",
|
||||
{ taskId, mode: "cloud", cloudProfileId: "primary" },
|
||||
vi.fn(),
|
||||
{ context: configuredCloudContext() },
|
||||
);
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({ message: "dispatch unavailable" });
|
||||
expect(deleteSession).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.handleChatSend).not.toHaveBeenCalled();
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
});
|
||||
|
||||
it("sends an idle session acceptance as a new turn and replays its source key", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
await upsertSessionEntry(
|
||||
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
|
||||
{ sessionId: "source-session", updatedAt: 1 },
|
||||
);
|
||||
const taskId = await createSourceSuggestion();
|
||||
const client = operatorClient();
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
|
||||
client,
|
||||
context: { chatAbortControllers: new Map() },
|
||||
});
|
||||
const replay = await call("taskSuggestions.accept", { taskId, mode: "session" });
|
||||
|
||||
expect(accepted.response?.[1]).toEqual({ taskId, key: SOURCE_SESSION_KEY });
|
||||
expect(replay.response?.[1]).toEqual({ taskId, key: SOURCE_SESSION_KEY });
|
||||
expect(mocks.handleChatSend).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.handleChatSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
client,
|
||||
params: {
|
||||
sessionKey: SOURCE_SESSION_KEY,
|
||||
agentId: "main",
|
||||
sessionId: "source-session",
|
||||
message: "Apply the focused fix in this session.",
|
||||
idempotencyKey: `task-suggestion:${taskId}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("steers a session acceptance into its one exact active run", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
await upsertSessionEntry(
|
||||
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
|
||||
{ sessionId: "source-session", updatedAt: 1 },
|
||||
);
|
||||
const taskId = await createSourceSuggestion();
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
|
||||
client: operatorClient(),
|
||||
context: {
|
||||
chatAbortControllers: new Map([
|
||||
[
|
||||
"run-one",
|
||||
{ sessionKey: SOURCE_SESSION_KEY, sessionId: "source-session", agentId: "main" },
|
||||
],
|
||||
]) as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(true);
|
||||
expect(mocks.handleChatSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ queueMode: "steer", expectedRunId: "run-one" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "multiple run IDs", runIds: ["run-one", "run-two"], projected: false },
|
||||
{ label: "no exact run ID", runIds: [], projected: true },
|
||||
])("rejects an active session with $label and restores the suggestion", async (testCase) => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
await upsertSessionEntry(
|
||||
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
|
||||
{ sessionId: "source-session", updatedAt: 1 },
|
||||
);
|
||||
const taskId = await createSourceSuggestion();
|
||||
const deleteSession = vi.spyOn(sessionDeleteHandlers, "sessions.delete");
|
||||
if (testCase.projected) {
|
||||
registerAgentRunContext("projected-task-suggestion-run", {
|
||||
projectSessionActive: true,
|
||||
sessionId: "source-session",
|
||||
sessionKey: SOURCE_SESSION_KEY,
|
||||
});
|
||||
}
|
||||
const activeRuns = new Map(
|
||||
testCase.runIds.map((runId) => [
|
||||
runId,
|
||||
{
|
||||
sessionKey: SOURCE_SESSION_KEY,
|
||||
sessionId: "source-session",
|
||||
agentId: "main",
|
||||
runId,
|
||||
},
|
||||
]),
|
||||
);
|
||||
try {
|
||||
const accepted = await call(
|
||||
"taskSuggestions.accept",
|
||||
{ taskId, mode: "session" },
|
||||
vi.fn(),
|
||||
{
|
||||
client: operatorClient(),
|
||||
context: { chatAbortControllers: activeRuns as never },
|
||||
},
|
||||
);
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
details: { code: "SESSION_SUGGESTION_ACTIVE_RUN_AMBIGUOUS" },
|
||||
});
|
||||
if (testCase.projected) {
|
||||
expect(accepted.response?.[2]?.message).toBe(
|
||||
"active session run has no exact dispatch identity; refresh and retry",
|
||||
);
|
||||
}
|
||||
expect(mocks.handleChatSend).not.toHaveBeenCalled();
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
} finally {
|
||||
if (testCase.projected) {
|
||||
clearAgentRunContext("projected-task-suggestion-run");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing source session and restores the suggestion", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const taskId = await createSourceSuggestion();
|
||||
const deleteSession = vi.spyOn(sessionDeleteHandlers, "sessions.delete");
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
|
||||
client: operatorClient(),
|
||||
context: { chatAbortControllers: new Map() },
|
||||
});
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "source session no longer exists; start it in a worktree instead",
|
||||
});
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("restores a session-mode suggestion after delivery failure without deleting its source", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
await upsertSessionEntry(
|
||||
{ agentId: "main", sessionKey: SOURCE_SESSION_KEY },
|
||||
{ sessionId: "source-session", updatedAt: 1 },
|
||||
);
|
||||
const taskId = await createSourceSuggestion();
|
||||
const deleteSession = vi.spyOn(sessionDeleteHandlers, "sessions.delete");
|
||||
mocks.handleChatSend.mockImplementationOnce(async ({ respond }: { respond: RespondFn }) => {
|
||||
respond(false, undefined, { code: "UNAVAILABLE", message: "delivery unavailable" });
|
||||
});
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId, mode: "session" }, vi.fn(), {
|
||||
client: operatorClient(),
|
||||
context: { chatAbortControllers: new Map() },
|
||||
});
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({ message: "delivery unavailable" });
|
||||
expect(deleteSession).not.toHaveBeenCalled();
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid acceptance mode before claiming the suggestion", async () => {
|
||||
const taskId = await createSourceSuggestion();
|
||||
const createSession = vi.spyOn(sessionCreateHandlers, "sessions.create");
|
||||
|
||||
const accepted = await call("taskSuggestions.accept", { taskId, mode: "remote" });
|
||||
const listed = await call("taskSuggestions.list", {});
|
||||
|
||||
expect(accepted.response?.[0]).toBe(false);
|
||||
expect(accepted.response?.[2]).toMatchObject({ code: "INVALID_REQUEST" });
|
||||
expect(accepted.response?.[2]?.message).toContain("mode");
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(listed.response?.[1]).toMatchObject({ suggestions: [{ id: taskId }] });
|
||||
});
|
||||
|
||||
it("rolls back an empty session and keeps a failed seed suggestion pending", async () => {
|
||||
const created = await call("taskSuggestions.create", {
|
||||
title: "Add coverage",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
type TaskSuggestion,
|
||||
type TaskSuggestionsAcceptParams,
|
||||
type TaskSuggestionsAcceptResult,
|
||||
validateTaskSuggestionsAcceptParams,
|
||||
validateTaskSuggestionsCreateParams,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { insideGitCheckout } from "../../agents/worktrees/git.js";
|
||||
import { managedWorktrees } from "../../agents/worktrees/service.js";
|
||||
import { resolveSessionWorkStartError } from "../../config/sessions.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { buildDashboardSessionKey } from "../session-create-service.js";
|
||||
@@ -27,8 +29,12 @@ import {
|
||||
dismissTaskSuggestion,
|
||||
listTaskSuggestions,
|
||||
} from "../task-suggestion-registry.js";
|
||||
import { handleChatSend } from "./chat-send-handler.js";
|
||||
import { listWorkerProfiles } from "./environments.js";
|
||||
import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js";
|
||||
import { sessionCreateHandlers } from "./sessions-create.js";
|
||||
import { sessionDeleteHandlers } from "./sessions-delete.js";
|
||||
import { sessionDispatchHandlers } from "./sessions-dispatch.js";
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
function invalidParams(method: string, errors: Parameters<typeof formatValidationErrors>[0]) {
|
||||
@@ -42,6 +48,8 @@ type TaskSuggestionAcceptanceResult =
|
||||
| { ok: true; result: TaskSuggestionsAcceptResult }
|
||||
| { ok: false; error: NonNullable<Parameters<RespondFn>[2]> };
|
||||
|
||||
type TaskSuggestionAcceptMode = NonNullable<TaskSuggestionsAcceptParams["mode"]>;
|
||||
|
||||
const activeAcceptances = new Map<string, Promise<TaskSuggestionAcceptanceResult>>();
|
||||
|
||||
function abandonSuggestedTaskAcceptance(
|
||||
@@ -143,18 +151,98 @@ async function failSuggestedTaskSession(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function finishSuggestedTaskAcceptance(params: {
|
||||
taskId: string;
|
||||
sessionKey: string;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
}): TaskSuggestionAcceptanceResult {
|
||||
completeTaskSuggestionAcceptance(params.taskId, params.sessionKey);
|
||||
params.options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: params.taskId, resolution: "accepted" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
return { ok: true, result: { taskId: params.taskId, key: params.sessionKey } };
|
||||
}
|
||||
|
||||
function failSuggestedTaskDelivery(params: {
|
||||
taskId: string;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
error: NonNullable<Parameters<RespondFn>[2]>;
|
||||
}): TaskSuggestionAcceptanceResult {
|
||||
// Session-mode delivery owns only the registry claim. Never roll back the
|
||||
// operator-owned source session or its worktree when message delivery fails.
|
||||
const restored = cancelTaskSuggestionAcceptance(params.taskId);
|
||||
if (restored) {
|
||||
params.options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "created", suggestion: restored },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
}
|
||||
return { ok: false, error: params.error };
|
||||
}
|
||||
|
||||
function resolveSuggestionAgentId(
|
||||
suggestion: TaskSuggestion,
|
||||
options: GatewayRequestHandlerOptions,
|
||||
): string {
|
||||
return normalizeAgentId(
|
||||
suggestion.agentId ??
|
||||
parseAgentSessionKey(suggestion.sessionKey)?.agentId ??
|
||||
resolveDefaultAgentId(options.context.getRuntimeConfig()),
|
||||
);
|
||||
}
|
||||
|
||||
async function sendSuggestedTaskPrompt(params: {
|
||||
taskId: string;
|
||||
suggestion: TaskSuggestion;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
sessionId?: string;
|
||||
activeRunId?: string;
|
||||
}): Promise<Parameters<RespondFn> | undefined> {
|
||||
let response: Parameters<RespondFn> | undefined;
|
||||
const chatParams = {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
||||
message: params.suggestion.prompt,
|
||||
...(params.activeRunId
|
||||
? { queueMode: "steer" as const, expectedRunId: params.activeRunId }
|
||||
: {}),
|
||||
idempotencyKey: `task-suggestion:${params.taskId}`,
|
||||
};
|
||||
await handleChatSend({
|
||||
...params.options,
|
||||
req: { ...params.options.req, method: "chat.send", params: chatParams },
|
||||
params: chatParams,
|
||||
respond: (...args) => {
|
||||
response = args;
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async function createSuggestedTaskSession(params: {
|
||||
taskId: string;
|
||||
suggestion: TaskSuggestion;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
mode: Exclude<TaskSuggestionAcceptMode, "session">;
|
||||
cloudProfileId?: string;
|
||||
}): Promise<TaskSuggestionAcceptanceResult> {
|
||||
let sessionResponse: Parameters<RespondFn> | undefined;
|
||||
const agentId = normalizeAgentId(
|
||||
params.suggestion.agentId ??
|
||||
parseAgentSessionKey(params.suggestion.sessionKey)?.agentId ??
|
||||
resolveDefaultAgentId(params.options.context.getRuntimeConfig()),
|
||||
);
|
||||
const agentId = resolveSuggestionAgentId(params.suggestion, params.options);
|
||||
const sessionKey = buildDashboardSessionKey(agentId);
|
||||
const fail = (key: string, error: NonNullable<Parameters<RespondFn>[2]>) =>
|
||||
failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey: key,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error,
|
||||
});
|
||||
try {
|
||||
await sessionCreateHandlers["sessions.create"]?.({
|
||||
...params.options,
|
||||
@@ -163,8 +251,8 @@ async function createSuggestedTaskSession(params: {
|
||||
agentId,
|
||||
parentSessionKey: params.suggestion.sessionKey,
|
||||
label: params.suggestion.title,
|
||||
task: params.suggestion.prompt,
|
||||
worktree: true,
|
||||
...(params.mode === "cloud" ? {} : { task: params.suggestion.prompt }),
|
||||
...(params.mode === "local" ? {} : { worktree: true }),
|
||||
cwd: params.suggestion.cwd,
|
||||
},
|
||||
respond: (...args) => {
|
||||
@@ -172,44 +260,82 @@ async function createSuggestedTaskSession(params: {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)),
|
||||
});
|
||||
return await fail(sessionKey, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
if (!sessionResponse) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
return await fail(
|
||||
sessionKey,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"),
|
||||
});
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"),
|
||||
);
|
||||
}
|
||||
const [ok, payload, error] = sessionResponse;
|
||||
const [ok, payload, sessionError] = sessionResponse;
|
||||
if (!ok) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
return await fail(
|
||||
sessionKey,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: error ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create suggested task"),
|
||||
});
|
||||
sessionError ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create suggested task"),
|
||||
);
|
||||
}
|
||||
const key =
|
||||
payload && typeof payload === "object" && typeof (payload as { key?: unknown }).key === "string"
|
||||
? (payload as { key: string }).key
|
||||
: undefined;
|
||||
if (!key) {
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
return await fail(
|
||||
sessionKey,
|
||||
agentId,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "sessions.create returned no session key"),
|
||||
);
|
||||
}
|
||||
if (params.mode === "cloud") {
|
||||
let dispatchResponse: Parameters<RespondFn> | undefined;
|
||||
try {
|
||||
await sessionDispatchHandlers["sessions.dispatch"]?.({
|
||||
...params.options,
|
||||
params: { key, agentId, profileId: params.cloudProfileId },
|
||||
respond: (...args) => {
|
||||
dispatchResponse = args;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return await fail(key, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
if (!dispatchResponse?.[0]) {
|
||||
return await fail(
|
||||
key,
|
||||
dispatchResponse?.[2] ??
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
dispatchResponse
|
||||
? "failed to dispatch suggested task"
|
||||
: "sessions.dispatch did not respond",
|
||||
),
|
||||
);
|
||||
}
|
||||
let sendResponse: Parameters<RespondFn> | undefined;
|
||||
try {
|
||||
sendResponse = await sendSuggestedTaskPrompt({
|
||||
taskId: params.taskId,
|
||||
suggestion: params.suggestion,
|
||||
options: params.options,
|
||||
sessionKey: key,
|
||||
agentId,
|
||||
});
|
||||
} catch (error) {
|
||||
return await fail(key, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
if (!sendResponse?.[0]) {
|
||||
return await fail(
|
||||
key,
|
||||
sendResponse?.[2] ??
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
sendResponse ? "failed to deliver suggested task" : "chat.send did not respond",
|
||||
),
|
||||
);
|
||||
}
|
||||
return finishSuggestedTaskAcceptance({
|
||||
taskId: params.taskId,
|
||||
sessionKey: key,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create returned no session key"),
|
||||
});
|
||||
}
|
||||
const result = payload as { runError?: unknown; runStarted?: unknown };
|
||||
@@ -220,21 +346,96 @@ async function createSuggestedTaskSession(params: {
|
||||
typeof (result.runError as { message?: unknown }).message === "string"
|
||||
? (result.runError as { message: string }).message
|
||||
: "initial task did not start";
|
||||
return await failSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
sessionKey: key,
|
||||
agentId,
|
||||
options: params.options,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, runMessage),
|
||||
});
|
||||
return await fail(key, errorShape(ErrorCodes.UNAVAILABLE, runMessage));
|
||||
}
|
||||
completeTaskSuggestionAcceptance(params.taskId, key);
|
||||
params.options.context.broadcast(
|
||||
"task.suggestion",
|
||||
{ action: "resolved", taskId: params.taskId, resolution: "accepted" },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
return { ok: true, result: { taskId: params.taskId, key } };
|
||||
return finishSuggestedTaskAcceptance({
|
||||
taskId: params.taskId,
|
||||
sessionKey: key,
|
||||
options: params.options,
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverSuggestedTaskToSourceSession(params: {
|
||||
taskId: string;
|
||||
suggestion: TaskSuggestion;
|
||||
options: GatewayRequestHandlerOptions;
|
||||
}): Promise<TaskSuggestionAcceptanceResult> {
|
||||
const agentId = resolveSuggestionAgentId(params.suggestion, params.options);
|
||||
const fail = (error: NonNullable<Parameters<RespondFn>[2]>) =>
|
||||
failSuggestedTaskDelivery({ taskId: params.taskId, options: params.options, error });
|
||||
let source: ReturnType<typeof loadSessionEntryReadOnly>;
|
||||
try {
|
||||
source = loadSessionEntryReadOnly(params.suggestion.sessionKey, { agentId });
|
||||
} catch (error) {
|
||||
return fail(errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
if (!source.entry?.sessionId) {
|
||||
return fail(
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"source session no longer exists; start it in a worktree instead",
|
||||
),
|
||||
);
|
||||
}
|
||||
const lifecycleError = resolveSessionWorkStartError(source.canonicalKey, source.entry);
|
||||
if (lifecycleError) {
|
||||
return fail(errorShape(ErrorCodes.INVALID_REQUEST, lifecycleError));
|
||||
}
|
||||
let activeRunState: ReturnType<typeof resolveVisibleActiveSessionRunState>;
|
||||
try {
|
||||
activeRunState = resolveVisibleActiveSessionRunState({
|
||||
context: params.options.context,
|
||||
requestedKey: params.suggestion.sessionKey,
|
||||
canonicalKey: source.canonicalKey,
|
||||
sessionId: source.entry.sessionId,
|
||||
agentId,
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
if (activeRunState.active && activeRunState.runIds.length !== 1) {
|
||||
const message =
|
||||
activeRunState.runIds.length === 0
|
||||
? "active session run has no exact dispatch identity; refresh and retry"
|
||||
: "session has multiple active runs; choose the target run before accepting the task suggestion";
|
||||
return fail(
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, message, {
|
||||
retryable: false,
|
||||
details: {
|
||||
code: "SESSION_SUGGESTION_ACTIVE_RUN_AMBIGUOUS",
|
||||
sessionKey: params.suggestion.sessionKey,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
let sendResponse: Parameters<RespondFn> | undefined;
|
||||
try {
|
||||
sendResponse = await sendSuggestedTaskPrompt({
|
||||
taskId: params.taskId,
|
||||
suggestion: params.suggestion,
|
||||
options: params.options,
|
||||
sessionKey: params.suggestion.sessionKey,
|
||||
agentId,
|
||||
sessionId: source.entry.sessionId,
|
||||
activeRunId: activeRunState.runIds[0],
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
if (!sendResponse?.[0]) {
|
||||
return fail(
|
||||
sendResponse?.[2] ??
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
sendResponse ? "failed to deliver suggested task" : "chat.send did not respond",
|
||||
),
|
||||
);
|
||||
}
|
||||
return finishSuggestedTaskAcceptance({
|
||||
taskId: params.taskId,
|
||||
sessionKey: params.suggestion.sessionKey,
|
||||
options: params.options,
|
||||
});
|
||||
}
|
||||
|
||||
export const taskSuggestionsHandlers: GatewayRequestHandlers = {
|
||||
@@ -328,6 +529,33 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mode = params.mode ?? "worktree";
|
||||
let cloudProfileId: string | undefined;
|
||||
if (mode === "cloud") {
|
||||
const profiles = listWorkerProfiles(options.context);
|
||||
if (profiles.length === 0) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "no cloud worker profiles configured"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
cloudProfileId = params.cloudProfileId;
|
||||
if (!cloudProfileId || !profiles.some((profile) => profile.id === cloudProfileId)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
cloudProfileId
|
||||
? `unknown cloud worker profile: ${cloudProfileId}`
|
||||
: "cloudProfileId is required for cloud mode",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const active = activeAcceptances.get(params.taskId);
|
||||
if (active) {
|
||||
const outcome = await active;
|
||||
@@ -354,11 +582,21 @@ export const taskSuggestionsHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pending = createSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
suggestion: acceptance.suggestion,
|
||||
options,
|
||||
}).catch((error: unknown) => {
|
||||
const pending = (
|
||||
mode === "session"
|
||||
? deliverSuggestedTaskToSourceSession({
|
||||
taskId: params.taskId,
|
||||
suggestion: acceptance.suggestion,
|
||||
options,
|
||||
})
|
||||
: createSuggestedTaskSession({
|
||||
taskId: params.taskId,
|
||||
suggestion: acceptance.suggestion,
|
||||
options,
|
||||
mode,
|
||||
...(cloudProfileId ? { cloudProfileId } : {}),
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
abandonSuggestedTaskAcceptance(params.taskId, options);
|
||||
throw error;
|
||||
});
|
||||
|
||||
@@ -177,6 +177,9 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
expect(payload?.features?.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
|
||||
);
|
||||
expect(payload?.features?.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
|
||||
);
|
||||
expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath);
|
||||
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR);
|
||||
expect(payload?.policy?.allowedSessionVisibilities).toEqual([
|
||||
|
||||
@@ -102,6 +102,7 @@ export async function sendGatewayHello(
|
||||
GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC,
|
||||
GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
|
||||
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
|
||||
],
|
||||
},
|
||||
snapshot,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Covers gateway-backed chat behavior used by the TUI backend.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
// Covers gateway-backed chat behavior used by the TUI backend.
|
||||
import { GATEWAY_SERVER_CAPS } from "../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
const { GatewayChatClient } = await import("./gateway-chat.js");
|
||||
const { GatewayClientRequestError } = await import("../gateway/client.js");
|
||||
@@ -399,7 +400,7 @@ describe("GatewayChatClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists, accepts, and dismisses task suggestions through the gateway", async () => {
|
||||
it("lists profiles and serializes task suggestion acceptance modes", async () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
@@ -417,29 +418,60 @@ describe("GatewayChatClient", () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ suggestions: [suggestion] })
|
||||
.mockResolvedValueOnce({ profiles: [{ id: "build", providerId: "crabbox" }] })
|
||||
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:task" })
|
||||
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:task" })
|
||||
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:local" })
|
||||
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:session" })
|
||||
.mockResolvedValueOnce({ taskId: "task_1", key: "agent:main:cloud" })
|
||||
.mockResolvedValueOnce({ taskId: "task_2", dismissed: true });
|
||||
client.hello = {
|
||||
features: {
|
||||
methods: ["taskSuggestions.list", "taskSuggestions.accept", "taskSuggestions.dismiss"],
|
||||
methods: [
|
||||
"environments.list",
|
||||
"taskSuggestions.list",
|
||||
"taskSuggestions.accept",
|
||||
"taskSuggestions.dismiss",
|
||||
],
|
||||
capabilities: [GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES],
|
||||
},
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
} as never;
|
||||
(client as unknown as { client: { request: typeof request } }).client.request = request;
|
||||
|
||||
await expect(client.listTaskSuggestions()).resolves.toEqual([suggestion]);
|
||||
await expect(client.listCloudWorkerProfiles()).resolves.toEqual(["build"]);
|
||||
await expect(client.acceptTaskSuggestion("task_1")).resolves.toEqual({
|
||||
taskId: "task_1",
|
||||
key: "agent:main:task",
|
||||
});
|
||||
await client.acceptTaskSuggestion("task_1", "worktree");
|
||||
await client.acceptTaskSuggestion("task_1", "local");
|
||||
await client.acceptTaskSuggestion("task_1", "session");
|
||||
await client.acceptTaskSuggestion("task_1", "cloud", "build");
|
||||
await expect(client.dismissTaskSuggestion("task_2")).resolves.toEqual({
|
||||
taskId: "task_2",
|
||||
dismissed: true,
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "taskSuggestions.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "taskSuggestions.accept", { taskId: "task_1" });
|
||||
expect(request).toHaveBeenNthCalledWith(3, "taskSuggestions.dismiss", { taskId: "task_2" });
|
||||
expect(request).toHaveBeenNthCalledWith(2, "environments.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "taskSuggestions.accept", { taskId: "task_1" });
|
||||
expect(request).toHaveBeenNthCalledWith(4, "taskSuggestions.accept", { taskId: "task_1" });
|
||||
expect(request).toHaveBeenNthCalledWith(5, "taskSuggestions.accept", {
|
||||
taskId: "task_1",
|
||||
mode: "local",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(6, "taskSuggestions.accept", {
|
||||
taskId: "task_1",
|
||||
mode: "session",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(7, "taskSuggestions.accept", {
|
||||
taskId: "task_1",
|
||||
mode: "cloud",
|
||||
cloudProfileId: "build",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(8, "taskSuggestions.dismiss", { taskId: "task_2" });
|
||||
});
|
||||
|
||||
it("derives task suggestion actions from negotiated methods and scopes", () => {
|
||||
@@ -456,6 +488,20 @@ describe("GatewayChatClient", () => {
|
||||
|
||||
expect(client.getTaskSuggestionActionCapabilities()).toEqual({
|
||||
canAccept: false,
|
||||
canAcceptModes: false,
|
||||
canDismiss: true,
|
||||
});
|
||||
|
||||
client.hello = {
|
||||
features: {
|
||||
methods: ["taskSuggestions.accept", "taskSuggestions.dismiss"],
|
||||
capabilities: [GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES],
|
||||
},
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
} as never;
|
||||
expect(client.getTaskSuggestionActionCapabilities()).toEqual({
|
||||
canAccept: true,
|
||||
canAcceptModes: true,
|
||||
canDismiss: true,
|
||||
});
|
||||
});
|
||||
@@ -470,6 +516,19 @@ describe("GatewayChatClient", () => {
|
||||
(client as unknown as { client: { request: typeof request } }).client.request = request;
|
||||
|
||||
await expect(client.listTaskSuggestions()).resolves.toEqual([]);
|
||||
await expect(client.listCloudWorkerProfiles()).resolves.toEqual([]);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps cloud profile discovery failures quiet", async () => {
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
});
|
||||
const request = vi.fn().mockRejectedValue(new Error("not available"));
|
||||
client.hello = { features: { methods: ["environments.list"] } } as never;
|
||||
(client as unknown as { client: { request: typeof request } }).client.request = request;
|
||||
|
||||
await expect(client.listCloudWorkerProfiles()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+31
-4
@@ -12,15 +12,18 @@ import {
|
||||
} from "../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import {
|
||||
type HelloOk,
|
||||
GATEWAY_SERVER_CAPS,
|
||||
MIN_CLIENT_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
type CommandEntry,
|
||||
type CommandsListParams,
|
||||
type CommandsListResult,
|
||||
type EnvironmentsListResult,
|
||||
type SessionsListParams,
|
||||
type SessionsPatchResult,
|
||||
type SessionsPatchParams,
|
||||
type TaskSuggestionsAcceptResult,
|
||||
type TaskSuggestionsAcceptParams,
|
||||
type TaskSuggestionsListResult,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
@@ -52,6 +55,7 @@ import type {
|
||||
TuiSessionCreateOptions,
|
||||
TuiSessionMutationResult,
|
||||
TuiChatSendResult,
|
||||
TuiTaskSuggestionAcceptMode,
|
||||
} from "./tui-backend.js";
|
||||
|
||||
type GatewayConnectionOptions = {
|
||||
@@ -440,6 +444,7 @@ export class GatewayChatClient implements TuiBackend {
|
||||
getTaskSuggestionActionCapabilities() {
|
||||
const auth = this.hello?.auth;
|
||||
const methods = this.hello?.features?.methods;
|
||||
const capabilities = this.hello?.features?.capabilities;
|
||||
const allows = (method: string, scope: "operator.admin" | "operator.write") =>
|
||||
Array.isArray(methods) &&
|
||||
methods.includes(method) &&
|
||||
@@ -453,6 +458,9 @@ export class GatewayChatClient implements TuiBackend {
|
||||
);
|
||||
return {
|
||||
canAccept: allows("taskSuggestions.accept", "operator.admin"),
|
||||
canAcceptModes:
|
||||
Array.isArray(capabilities) &&
|
||||
capabilities.includes(GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES),
|
||||
canDismiss: allows("taskSuggestions.dismiss", "operator.write"),
|
||||
};
|
||||
}
|
||||
@@ -469,10 +477,29 @@ export class GatewayChatClient implements TuiBackend {
|
||||
return result.suggestions;
|
||||
}
|
||||
|
||||
async acceptTaskSuggestion(taskId: string) {
|
||||
return await this.client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", {
|
||||
taskId,
|
||||
});
|
||||
async listCloudWorkerProfiles() {
|
||||
if (this.hello?.features?.methods?.includes("environments.list") !== true) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const result = await this.client.request<EnvironmentsListResult>("environments.list", {});
|
||||
return result.profiles?.map((profile) => profile.id) ?? [];
|
||||
} catch {
|
||||
// Cloud placement is optional; older or temporarily failing gateways stay quiet.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async acceptTaskSuggestion(
|
||||
taskId: string,
|
||||
mode?: TuiTaskSuggestionAcceptMode,
|
||||
cloudProfileId?: string,
|
||||
) {
|
||||
const params: TaskSuggestionsAcceptParams =
|
||||
!mode || mode === "worktree"
|
||||
? { taskId }
|
||||
: { taskId, mode, ...(cloudProfileId ? { cloudProfileId } : {}) };
|
||||
return await this.client.request<TaskSuggestionsAcceptResult>("taskSuggestions.accept", params);
|
||||
}
|
||||
|
||||
async dismissTaskSuggestion(taskId: string) {
|
||||
|
||||
+10
-1
@@ -7,6 +7,7 @@ import type {
|
||||
SessionsPatchParams,
|
||||
SessionsPatchResult,
|
||||
TaskSuggestion,
|
||||
TaskSuggestionsAcceptParams,
|
||||
TaskSuggestionsAcceptResult,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js";
|
||||
@@ -33,9 +34,12 @@ export type TuiApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
type TuiTaskSuggestionActionCapabilities = {
|
||||
canAccept: boolean;
|
||||
canAcceptModes: boolean;
|
||||
canDismiss: boolean;
|
||||
};
|
||||
|
||||
export type TuiTaskSuggestionAcceptMode = NonNullable<TaskSuggestionsAcceptParams["mode"]>;
|
||||
|
||||
export type TuiPluginApproval = {
|
||||
id: string;
|
||||
request: {
|
||||
@@ -212,7 +216,12 @@ export type TuiBackend = {
|
||||
resolvePluginApproval?: (id: string, decision: TuiApprovalDecision) => Promise<{ ok?: boolean }>;
|
||||
getTaskSuggestionActionCapabilities?: () => TuiTaskSuggestionActionCapabilities;
|
||||
listTaskSuggestions?: () => Promise<TaskSuggestion[]>;
|
||||
acceptTaskSuggestion?: (taskId: string) => Promise<TaskSuggestionsAcceptResult>;
|
||||
listCloudWorkerProfiles?: () => Promise<string[]>;
|
||||
acceptTaskSuggestion?: (
|
||||
taskId: string,
|
||||
mode?: TuiTaskSuggestionAcceptMode,
|
||||
cloudProfileId?: string,
|
||||
) => Promise<TaskSuggestionsAcceptResult>;
|
||||
dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>;
|
||||
runGoalCommand?: (
|
||||
opts: TuiGoalCommandOptions,
|
||||
|
||||
@@ -642,7 +642,7 @@ describe.sequential("TUI PTY harness", () => {
|
||||
|
||||
await fixture.run.write("\x1b[A", { delay: false });
|
||||
await fixture.run.write("\r", { delay: false });
|
||||
await fixture.run.waitForOutput("Press Enter again to start this task in a worktree.");
|
||||
await fixture.run.waitForOutput("Press Enter again to start this task.");
|
||||
await fixture.run.write("\r", { delay: false });
|
||||
await fixture.waitForLogEntry(
|
||||
(entry) =>
|
||||
|
||||
@@ -53,6 +53,7 @@ function createHarness() {
|
||||
});
|
||||
const requestRender = vi.fn();
|
||||
const listTaskSuggestions = vi.fn().mockResolvedValue([]);
|
||||
const listCloudWorkerProfiles = vi.fn().mockResolvedValue([]);
|
||||
const acceptTaskSuggestion = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ taskId: "task_1", key: "agent:main:task" });
|
||||
@@ -60,11 +61,12 @@ function createHarness() {
|
||||
const onAccepted = vi.fn().mockResolvedValue(undefined);
|
||||
let agentId = "main";
|
||||
let sessionKey = "agent:main:main";
|
||||
let actionCapabilities = { canAccept: true, canDismiss: true };
|
||||
let actionCapabilities = { canAccept: true, canAcceptModes: true, canDismiss: true };
|
||||
const controller = createTuiTaskSuggestionController({
|
||||
client: {
|
||||
getTaskSuggestionActionCapabilities: () => actionCapabilities,
|
||||
listTaskSuggestions,
|
||||
listCloudWorkerProfiles,
|
||||
acceptTaskSuggestion,
|
||||
dismissTaskSuggestion,
|
||||
},
|
||||
@@ -96,6 +98,7 @@ function createHarness() {
|
||||
overlayHandles,
|
||||
requestRender,
|
||||
listTaskSuggestions,
|
||||
listCloudWorkerProfiles,
|
||||
acceptTaskSuggestion,
|
||||
dismissTaskSuggestion,
|
||||
onAccepted,
|
||||
@@ -105,7 +108,11 @@ function createHarness() {
|
||||
setSessionKey: (value: string) => {
|
||||
sessionKey = value;
|
||||
},
|
||||
setActionCapabilities: (value: { canAccept: boolean; canDismiss: boolean }) => {
|
||||
setActionCapabilities: (value: {
|
||||
canAccept: boolean;
|
||||
canAcceptModes: boolean;
|
||||
canDismiss: boolean;
|
||||
}) => {
|
||||
actionCapabilities = value;
|
||||
},
|
||||
};
|
||||
@@ -139,8 +146,13 @@ describe("TUI task suggestions", () => {
|
||||
expect(renderedPrompt).toContain("Why: The adapter is unreachable");
|
||||
expect(renderedPrompt).toContain("Instructions:");
|
||||
expect(renderedPrompt).toContain("Delete the stale adapter and update its tests.");
|
||||
expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual(["accept", "dismiss"]);
|
||||
expect(harness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(1);
|
||||
expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual([
|
||||
"accept-local",
|
||||
"accept-session",
|
||||
"accept",
|
||||
"dismiss",
|
||||
]);
|
||||
expect(harness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(3);
|
||||
|
||||
const accept = { value: "accept", label: "Start in worktree" };
|
||||
harness.selectors[0]?.onSelect?.(accept);
|
||||
@@ -157,6 +169,57 @@ describe("TUI task suggestions", () => {
|
||||
expect(harness.addSystem).toHaveBeenCalledWith("follow-up task started in agent:main:task");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ value: "accept-local", mode: "local" as const },
|
||||
{ value: "accept-session", mode: "session" as const },
|
||||
])("forwards $mode acceptance after double Enter", async ({ value, mode }) => {
|
||||
const harness = createHarness();
|
||||
harness.controller.handleEvent("task.suggestion", {
|
||||
action: "created",
|
||||
suggestion: suggestionPayload(),
|
||||
});
|
||||
const action = expectDefined(
|
||||
harness.selectors[0]?.items.find((item) => item.value === value),
|
||||
`${mode} action`,
|
||||
);
|
||||
|
||||
harness.selectors[0]?.onSelect?.(action);
|
||||
expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled();
|
||||
harness.selectors[0]?.onSelect?.(action);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.acceptTaskSuggestion).toHaveBeenCalledWith("task_1", mode);
|
||||
});
|
||||
if (mode === "session") {
|
||||
expect(harness.onAccepted).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers one cloud action per profile and forwards the selected profile", async () => {
|
||||
const harness = createHarness();
|
||||
const suggestion = suggestionPayload();
|
||||
harness.listTaskSuggestions.mockResolvedValueOnce([suggestion]);
|
||||
harness.listCloudWorkerProfiles.mockResolvedValueOnce(["build", "review"]);
|
||||
|
||||
await harness.controller.refresh();
|
||||
|
||||
const cloudActions = harness.selectors[0]?.items.filter(
|
||||
(item) => item.value === "accept-cloud",
|
||||
);
|
||||
expect(cloudActions?.map((item) => item.label)).toEqual([
|
||||
"Send to cloud · build",
|
||||
"Send to cloud · review",
|
||||
]);
|
||||
const review = expectDefined(cloudActions?.[1], "review cloud action");
|
||||
harness.selectors[0]?.onSelect?.(review);
|
||||
expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled();
|
||||
harness.selectors[0]?.onSelect?.(review);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.acceptTaskSuggestion).toHaveBeenCalledWith("task_1", "cloud", "review");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps actions visible while paging through long instructions", () => {
|
||||
const harness = createHarness();
|
||||
const promptLines = Array.from(
|
||||
@@ -252,7 +315,11 @@ describe("TUI task suggestions", () => {
|
||||
|
||||
it("offers only actions allowed by the connected operator scopes", () => {
|
||||
const writeHarness = createHarness();
|
||||
writeHarness.setActionCapabilities({ canAccept: false, canDismiss: true });
|
||||
writeHarness.setActionCapabilities({
|
||||
canAccept: false,
|
||||
canAcceptModes: true,
|
||||
canDismiss: true,
|
||||
});
|
||||
writeHarness.controller.handleEvent("task.suggestion", {
|
||||
action: "created",
|
||||
suggestion: suggestionPayload(),
|
||||
@@ -261,7 +328,11 @@ describe("TUI task suggestions", () => {
|
||||
expect(writeHarness.selectors[0]?.setSelectedIndex).toHaveBeenCalledWith(0);
|
||||
|
||||
const readHarness = createHarness();
|
||||
readHarness.setActionCapabilities({ canAccept: false, canDismiss: false });
|
||||
readHarness.setActionCapabilities({
|
||||
canAccept: false,
|
||||
canAcceptModes: true,
|
||||
canDismiss: false,
|
||||
});
|
||||
readHarness.controller.handleEvent("task.suggestion", {
|
||||
action: "created",
|
||||
suggestion: suggestionPayload(),
|
||||
@@ -269,6 +340,22 @@ describe("TUI task suggestions", () => {
|
||||
expect(readHarness.openOverlay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers only worktree acceptance when modes are not advertised", () => {
|
||||
const harness = createHarness();
|
||||
harness.setActionCapabilities({
|
||||
canAccept: true,
|
||||
canAcceptModes: false,
|
||||
canDismiss: true,
|
||||
});
|
||||
|
||||
harness.controller.handleEvent("task.suggestion", {
|
||||
action: "created",
|
||||
suggestion: suggestionPayload(),
|
||||
});
|
||||
|
||||
expect(harness.selectors[0]?.items.map((item) => item.value)).toEqual(["accept", "dismiss"]);
|
||||
});
|
||||
|
||||
it("rebuilds an active selector when reconnect changes action scopes", async () => {
|
||||
const harness = createHarness();
|
||||
const suggestion = suggestionPayload();
|
||||
@@ -278,7 +365,11 @@ describe("TUI task suggestions", () => {
|
||||
});
|
||||
const staleSelector = harness.selectors[0];
|
||||
|
||||
harness.setActionCapabilities({ canAccept: false, canDismiss: true });
|
||||
harness.setActionCapabilities({
|
||||
canAccept: false,
|
||||
canAcceptModes: true,
|
||||
canDismiss: true,
|
||||
});
|
||||
harness.listTaskSuggestions.mockResolvedValueOnce([suggestion]);
|
||||
await harness.controller.refresh();
|
||||
|
||||
@@ -290,6 +381,26 @@ describe("TUI task suggestions", () => {
|
||||
expect(harness.acceptTaskSuggestion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rebuilds an active selector when cloud profile identity changes", async () => {
|
||||
const harness = createHarness();
|
||||
const suggestion = suggestionPayload();
|
||||
harness.listTaskSuggestions.mockResolvedValue([suggestion]);
|
||||
harness.listCloudWorkerProfiles.mockResolvedValueOnce(["build"]);
|
||||
|
||||
await harness.controller.refresh();
|
||||
expect(harness.selectors[0]?.items.map((item) => item.label)).toContain(
|
||||
"Send to cloud · build",
|
||||
);
|
||||
|
||||
harness.listCloudWorkerProfiles.mockResolvedValueOnce(["review"]);
|
||||
await harness.controller.refresh();
|
||||
|
||||
expect(harness.closeOverlay).toHaveBeenCalledWith(harness.overlayHandles[0]);
|
||||
expect(harness.selectors[1]?.items.map((item) => item.label)).toContain(
|
||||
"Send to cloud · review",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a still-pending suggestion again when its action fails", async () => {
|
||||
const harness = createHarness();
|
||||
harness.acceptTaskSuggestion.mockRejectedValueOnce(new Error("gateway unavailable"));
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { TaskSuggestion } from "../../packages/gateway-protocol/src/index.j
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createTuiRefreshCoalescer } from "./coalesced-refresh.js";
|
||||
import { selectListTheme, theme } from "./theme/theme.js";
|
||||
import type { TuiBackend } from "./tui-backend.js";
|
||||
import type { TuiBackend, TuiTaskSuggestionAcceptMode } from "./tui-backend.js";
|
||||
import { sanitizeRenderableText } from "./tui-formatters.js";
|
||||
|
||||
type TaskSelector = Component & {
|
||||
@@ -26,6 +26,7 @@ type TaskSuggestionControllerDeps = {
|
||||
TuiBackend,
|
||||
| "getTaskSuggestionActionCapabilities"
|
||||
| "listTaskSuggestions"
|
||||
| "listCloudWorkerProfiles"
|
||||
| "acceptTaskSuggestion"
|
||||
| "dismissTaskSuggestion"
|
||||
>;
|
||||
@@ -45,18 +46,65 @@ const TASK_DETAIL_PAGE_LINES = TASK_DETAIL_VIEWPORT_LINES - 1;
|
||||
const PAGE_UP_INPUT = "\u001b[5~";
|
||||
const PAGE_DOWN_INPUT = "\u001b[6~";
|
||||
|
||||
const TASK_ACTIONS = [
|
||||
{
|
||||
type TaskAction = SelectItem & {
|
||||
kind: "accept" | "dismiss";
|
||||
mode?: TuiTaskSuggestionAcceptMode;
|
||||
cloudProfileId?: string;
|
||||
};
|
||||
|
||||
const TASK_ACTIONS = {
|
||||
worktree: {
|
||||
value: "accept",
|
||||
label: "Start in worktree",
|
||||
description: "Create an isolated session and begin this task",
|
||||
kind: "accept",
|
||||
mode: "worktree",
|
||||
},
|
||||
{
|
||||
local: {
|
||||
value: "accept-local",
|
||||
label: "Start locally",
|
||||
description: "Start a new session in this checkout",
|
||||
kind: "accept",
|
||||
mode: "local",
|
||||
},
|
||||
session: {
|
||||
value: "accept-session",
|
||||
label: "Fix in this session",
|
||||
description: "Deliver the task into this transcript",
|
||||
kind: "accept",
|
||||
mode: "session",
|
||||
},
|
||||
dismiss: {
|
||||
value: "dismiss",
|
||||
label: "Dismiss",
|
||||
description: "Leave the repository untouched",
|
||||
kind: "dismiss",
|
||||
},
|
||||
] satisfies SelectItem[];
|
||||
} as const satisfies Record<string, TaskAction>;
|
||||
|
||||
function taskActions(cloudProfileIds: string[]): TaskAction[] {
|
||||
return [
|
||||
TASK_ACTIONS.local,
|
||||
...cloudProfileIds.map(
|
||||
(profileId): TaskAction => ({
|
||||
value: "accept-cloud",
|
||||
label: `Send to cloud · ${clean(profileId)}`,
|
||||
description: "Start a new session on this cloud worker",
|
||||
kind: "accept",
|
||||
mode: "cloud",
|
||||
cloudProfileId: profileId,
|
||||
}),
|
||||
),
|
||||
TASK_ACTIONS.session,
|
||||
// Keep the established one-Up shortcut from the default Dismiss selection.
|
||||
TASK_ACTIONS.worktree,
|
||||
TASK_ACTIONS.dismiss,
|
||||
];
|
||||
}
|
||||
|
||||
function taskActionKey(action: SelectItem): string {
|
||||
return `${action.value}\0${(action as TaskAction).cloudProfileId ?? ""}`;
|
||||
}
|
||||
|
||||
function clean(text: string): string {
|
||||
return sanitizeTaskText(text.replace(/\s+/g, " ").trim());
|
||||
@@ -202,6 +250,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
let activeOverlay: OverlayHandle | null = null;
|
||||
let activeSelector: TaskSelector | null = null;
|
||||
let activeActionKey: string | null = null;
|
||||
let cloudProfileIds: string[] = [];
|
||||
let revision = 0;
|
||||
let disposed = false;
|
||||
|
||||
@@ -231,10 +280,14 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
const availableActions = () => {
|
||||
const capabilities = deps.client.getTaskSuggestionActionCapabilities?.() ?? {
|
||||
canAccept: Boolean(deps.client.acceptTaskSuggestion),
|
||||
canAcceptModes: false,
|
||||
canDismiss: Boolean(deps.client.dismissTaskSuggestion),
|
||||
};
|
||||
return TASK_ACTIONS.filter((action) =>
|
||||
action.value === "accept" ? capabilities.canAccept : capabilities.canDismiss,
|
||||
const actions: TaskAction[] = capabilities.canAcceptModes
|
||||
? taskActions(cloudProfileIds)
|
||||
: [TASK_ACTIONS.worktree, TASK_ACTIONS.dismiss];
|
||||
return actions.filter((action) =>
|
||||
action.kind === "accept" ? capabilities.canAccept : capabilities.canDismiss,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -243,7 +296,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
return;
|
||||
}
|
||||
const actions = availableActions();
|
||||
const actionKey = actions.map((action) => action.value).join(",");
|
||||
const actionKey = actions.map(taskActionKey).join(",");
|
||||
if (activeId) {
|
||||
if (activeActionKey === actionKey) {
|
||||
return;
|
||||
@@ -270,7 +323,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
let acceptArmed = false;
|
||||
let prompt: TaskPrompt | null = null;
|
||||
|
||||
const resolve = async (action: "accept" | "dismiss") => {
|
||||
const resolve = async (action: TaskAction) => {
|
||||
if (activeId !== suggestion.id || activeSelector !== selector) {
|
||||
return;
|
||||
}
|
||||
@@ -278,14 +331,22 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
hiddenIds.add(suggestion.id);
|
||||
deps.requestRender();
|
||||
try {
|
||||
if (action === "accept") {
|
||||
if (action.kind === "accept") {
|
||||
if (!deps.client.acceptTaskSuggestion) {
|
||||
throw new Error("task suggestion acceptance is unavailable");
|
||||
}
|
||||
const result = await deps.client.acceptTaskSuggestion(suggestion.id);
|
||||
const result = action.cloudProfileId
|
||||
? await deps.client.acceptTaskSuggestion(
|
||||
suggestion.id,
|
||||
action.mode,
|
||||
action.cloudProfileId,
|
||||
)
|
||||
: action.mode === "worktree"
|
||||
? await deps.client.acceptTaskSuggestion(suggestion.id)
|
||||
: await deps.client.acceptTaskSuggestion(suggestion.id, action.mode);
|
||||
remove(suggestion.id);
|
||||
deps.chatLog.addSystem(`follow-up task started in ${result.key}`);
|
||||
if (matchesSession(suggestion)) {
|
||||
if (action.mode !== "session" && matchesSession(suggestion)) {
|
||||
await deps.onAccepted(result.key);
|
||||
}
|
||||
} else {
|
||||
@@ -322,25 +383,28 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
if (activeSelector !== selector) {
|
||||
return;
|
||||
}
|
||||
if (!availableActions().some((action) => action.value === item.value)) {
|
||||
const selectedAction = actions.find(
|
||||
(action) => taskActionKey(action) === taskActionKey(item),
|
||||
);
|
||||
if (
|
||||
!selectedAction ||
|
||||
!availableActions().some((action) => taskActionKey(action) === taskActionKey(item))
|
||||
) {
|
||||
closeActive();
|
||||
presentNext();
|
||||
deps.requestRender();
|
||||
return;
|
||||
}
|
||||
if (item.value === "dismiss") {
|
||||
void resolve("dismiss");
|
||||
return;
|
||||
}
|
||||
if (item.value !== "accept") {
|
||||
if (selectedAction.kind === "dismiss") {
|
||||
void resolve(selectedAction);
|
||||
return;
|
||||
}
|
||||
if (acceptArmed) {
|
||||
void resolve("accept");
|
||||
void resolve(selectedAction);
|
||||
return;
|
||||
}
|
||||
acceptArmed = true;
|
||||
prompt?.setConfirmation("Press Enter again to start this task in a worktree.");
|
||||
prompt?.setConfirmation("Press Enter again to start this task.");
|
||||
deps.requestRender();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
@@ -361,7 +425,10 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
const refreshRunner = createTuiRefreshCoalescer(
|
||||
async (requestRerun) => {
|
||||
const startRevision = revision;
|
||||
const listed = await deps.client.listTaskSuggestions?.();
|
||||
const [listed, profiles] = await Promise.all([
|
||||
deps.client.listTaskSuggestions?.(),
|
||||
deps.client.listCloudWorkerProfiles?.() ?? Promise.resolve([]),
|
||||
]);
|
||||
if (disposed || !listed) {
|
||||
return false;
|
||||
}
|
||||
@@ -371,6 +438,7 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
|
||||
return true;
|
||||
}
|
||||
suggestions.clear();
|
||||
cloudProfileIds = profiles;
|
||||
for (const value of listed) {
|
||||
const suggestion = parseTuiTaskSuggestion(value);
|
||||
if (suggestion) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
chatSessionListResponse,
|
||||
createChatFlowE2eSuite,
|
||||
@@ -77,8 +78,12 @@ suite.define(() => {
|
||||
});
|
||||
await gateway.resolveDeferred("taskSuggestions.list", { suggestions: [] });
|
||||
|
||||
const startButton = page.getByRole("button", { name: "Start in worktree" });
|
||||
const startButton = page.getByRole("button", { name: "Start with worktree" });
|
||||
await startButton.waitFor({ state: "visible", timeout: 10_000 });
|
||||
expect(await page.getByRole("button", { name: "More ways to start this task" }).count()).toBe(
|
||||
0,
|
||||
);
|
||||
await page.getByText("Show instructions", { exact: true }).click();
|
||||
await page
|
||||
.getByText("/projects/example", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
@@ -96,6 +101,59 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fixes a model-suggested follow-up in the source session", async () => {
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const suggestion = {
|
||||
id: "task_session",
|
||||
title: "Repair the active flow",
|
||||
prompt: "Fix the active flow and keep this transcript selected.",
|
||||
tldr: "The follow-up belongs in this session.",
|
||||
cwd: "/projects/example",
|
||||
sessionKey: "main",
|
||||
agentId: "main",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureCapabilities: [GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES],
|
||||
featureMethods: [
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
"environments.list",
|
||||
"taskSuggestions.list",
|
||||
"taskSuggestions.accept",
|
||||
],
|
||||
methodResponses: {
|
||||
"environments.list": { environments: [], profiles: [] },
|
||||
"taskSuggestions.list": { suggestions: [suggestion] },
|
||||
"taskSuggestions.accept": { taskId: suggestion.id, key: suggestion.sessionKey },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const card = page.locator(`.task-suggestion[data-task-id="${suggestion.id}"]`);
|
||||
await card.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await gateway.waitForRequest("environments.list");
|
||||
const routeBeforeAccept = page.url();
|
||||
await card.getByRole("button", { name: "More ways to start this task" }).click();
|
||||
const sessionItem = card.locator('wa-dropdown-item[value="session"]');
|
||||
await sessionItem.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await sessionItem.click();
|
||||
|
||||
const acceptRequest = await gateway.waitForRequest("taskSuggestions.accept");
|
||||
expect(acceptRequest.params).toEqual({ taskId: suggestion.id, mode: "session" });
|
||||
await expect.poll(() => card.count()).toBe(0);
|
||||
expect(page.url()).toBe(routeBeforeAccept);
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears model-suggested follow-ups while switching sessions", async () => {
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
@@ -133,7 +191,7 @@ suite.define(() => {
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const startButton = page.getByRole("button", { name: "Start in worktree" });
|
||||
const startButton = page.getByRole("button", { name: "Start with worktree" });
|
||||
await startButton.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await gateway.deferNext("taskSuggestions.list");
|
||||
await page
|
||||
@@ -195,7 +253,7 @@ suite.define(() => {
|
||||
await page
|
||||
.locator(".agent-chat__composer-shell")
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
expect(await page.getByRole("button", { name: "Start in worktree" }).count()).toBe(0);
|
||||
expect(await page.getByRole("button", { name: "Start with worktree" }).count()).toBe(0);
|
||||
expect(await page.locator(".task-suggestion").count()).toBe(0);
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
|
||||
@@ -4604,13 +4604,21 @@ export const en: TranslationMap = {
|
||||
terminalUnavailable: "Terminal opening is unavailable for this session.",
|
||||
},
|
||||
taskSuggestions: {
|
||||
eyebrow: "Suggested follow-up",
|
||||
eyebrow: "Suggested task · in {repo}",
|
||||
start: "Start in worktree",
|
||||
startWorktree: "Start with worktree",
|
||||
startLocal: "Start locally",
|
||||
startCloud: "Send to cloud · {profile}",
|
||||
startCloudGeneric: "Send to cloud",
|
||||
fixInSession: "Fix in this session",
|
||||
noCloudConfigured: "No cloud environment configured",
|
||||
showInstructions: "Show instructions",
|
||||
moreActions: "More ways to start this task",
|
||||
starting: "Starting…",
|
||||
dismiss: "Dismiss {title}",
|
||||
project: "Project",
|
||||
instructions: "Instructions",
|
||||
adminRequired: "Administrator access is required to create a worktree from this project.",
|
||||
adminRequired: "Administrator access is required to start suggested tasks.",
|
||||
},
|
||||
pullRequests: {
|
||||
linkLabel: "Pull request #{number}: {title}",
|
||||
|
||||
@@ -61,6 +61,11 @@ type SessionWorktreeDisplayRow = {
|
||||
execNode?: string;
|
||||
};
|
||||
|
||||
/** Basename shown for a repository path on every Control UI surface. */
|
||||
export function repoName(repoRoot: string): string {
|
||||
return repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot;
|
||||
}
|
||||
|
||||
/** Compact "repo ⎇ branch" (plus node host) line for worktree/work sessions. */
|
||||
export function resolveSessionWorkSubtitle(row: SessionWorktreeDisplayRow): string | undefined {
|
||||
const repoRoot = normalizeOptionalString(row.worktree?.repoRoot);
|
||||
@@ -68,11 +73,11 @@ export function resolveSessionWorkSubtitle(row: SessionWorktreeDisplayRow): stri
|
||||
// execNode is often a raw node id (long hex); never render it in full.
|
||||
const rawNode = normalizeOptionalString(row.execNode);
|
||||
const node = rawNode ? shortenOpaqueIdRuns(rawNode) : undefined;
|
||||
const repoName = repoRoot ? (repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot) : undefined;
|
||||
const repo = repoRoot ? repoName(repoRoot) : undefined;
|
||||
const shortBranch = branch?.startsWith(WORKTREE_BRANCH_PREFIX)
|
||||
? branch.slice(WORKTREE_BRANCH_PREFIX.length)
|
||||
: branch;
|
||||
const checkout = repoName ? (shortBranch ? `${repoName} ⎇ ${shortBranch}` : repoName) : undefined;
|
||||
const checkout = repo ? (shortBranch ? `${repo} ⎇ ${shortBranch}` : repo) : undefined;
|
||||
if (checkout && node) {
|
||||
// Checkout first: it names the work; the node is routing detail.
|
||||
return `${checkout} · ${node}`;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TaskSuggestionsAcceptParams } from "../../../packages/gateway-protocol/src/index.js";
|
||||
|
||||
export type TaskSuggestionAcceptMode = NonNullable<TaskSuggestionsAcceptParams["mode"]>;
|
||||
|
||||
export function taskSuggestionAcceptParams(
|
||||
taskId: string,
|
||||
mode: TaskSuggestionAcceptMode,
|
||||
cloudProfileId?: string,
|
||||
): TaskSuggestionsAcceptParams {
|
||||
if (mode === "worktree") {
|
||||
return { taskId };
|
||||
}
|
||||
return { taskId, mode, ...(cloudProfileId ? { cloudProfileId } : {}) };
|
||||
}
|
||||
@@ -295,6 +295,12 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
||||
protected readonly taskSuggestionBusyIds = new Set<string>();
|
||||
protected readonly taskSuggestionOperations = new Map<string, symbol>();
|
||||
protected taskSuggestionsRequestVersion = 0;
|
||||
protected taskSuggestionCloudProfiles: Array<{ id: string }> = [];
|
||||
protected taskSuggestionCloudProfileGeneration = -1;
|
||||
protected resetTaskSuggestionCloudProfiles(): void {
|
||||
this.taskSuggestionCloudProfiles = [];
|
||||
this.taskSuggestionCloudProfileGeneration = -1;
|
||||
}
|
||||
protected sessionSuggestions: SessionSuggestion[] = [];
|
||||
protected sessionSuggestionRole: SessionSharingRole | undefined;
|
||||
protected readonly sessionSuggestionBusyIds = new Set<string>();
|
||||
|
||||
@@ -215,6 +215,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle {
|
||||
this.taskSuggestions = [];
|
||||
this.taskSuggestionBusyIds.clear();
|
||||
this.taskSuggestionOperations.clear();
|
||||
this.resetTaskSuggestionCloudProfiles();
|
||||
this.resetSessionSuggestions();
|
||||
this.clearTypingActors();
|
||||
this.sessionDiscussionStates.clear();
|
||||
|
||||
@@ -726,6 +726,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
||||
this.taskSuggestions = [];
|
||||
this.taskSuggestionBusyIds.clear();
|
||||
this.taskSuggestionOperations.clear();
|
||||
this.resetTaskSuggestionCloudProfiles();
|
||||
this.resetSessionSuggestions();
|
||||
this.clearTypingActors();
|
||||
this.resetSessionPullRequests();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { GATEWAY_SERVER_CAPS } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { findInlineApproval } from "../../app/approval-presentation.ts";
|
||||
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
|
||||
import { cancelQuestionPrompt, submitQuestionPrompt } from "../../app/question-prompt.ts";
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
resolveControlUiFollowUpMode,
|
||||
resolveControlUiServerQueueMode,
|
||||
} from "../../lib/chat/follow-up-mode.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import {
|
||||
isGatewayCapabilityAdvertised,
|
||||
isGatewayMethodAdvertised,
|
||||
} from "../../lib/gateway-methods.ts";
|
||||
import {
|
||||
pickFreshestObserverDigest,
|
||||
projectSessionObserverDigest,
|
||||
@@ -80,6 +84,7 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
|
||||
if (!state) {
|
||||
return html`<main class="app-shell app-shell--booting" aria-busy="true"></main>`;
|
||||
}
|
||||
void this.ensureTaskSuggestionCloudProfiles();
|
||||
const selectedSession = selectedChatSessionRow(state);
|
||||
const mutationAccess = readChatPaneMutationAccess(
|
||||
this.context.gateway.snapshot,
|
||||
@@ -448,12 +453,19 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
|
||||
state.connected &&
|
||||
hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
|
||||
isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.accept") === true,
|
||||
canAcceptTaskSuggestionModes:
|
||||
isGatewayCapabilityAdvertised(
|
||||
this.context.gateway.snapshot,
|
||||
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
|
||||
) === true,
|
||||
canDismissTaskSuggestions:
|
||||
state.connected &&
|
||||
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
|
||||
isGatewayMethodAdvertised(this.context.gateway.snapshot, "taskSuggestions.dismiss") ===
|
||||
true,
|
||||
onAcceptTaskSuggestion: (suggestion) => void this.acceptTaskSuggestion(suggestion),
|
||||
taskSuggestionCloudProfiles: this.taskSuggestionCloudProfiles,
|
||||
onAcceptTaskSuggestion: (suggestion, mode, cloudProfileId) =>
|
||||
void this.acceptTaskSuggestion(suggestion, mode, cloudProfileId),
|
||||
onDismissTaskSuggestion: (suggestion) => void this.dismissTaskSuggestion(suggestion),
|
||||
onOpenWorkspaceFile: (target) => openSessionWorkspaceFile(state, target),
|
||||
onRevealWorkspaceFile: (path) => revealSessionWorkspaceFile(state, path),
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { ControlUiSessionPullRequest } from "../../../../src/gateway/control-ui-contract.js";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import { selectApplicationSession } from "../../app/agent-selection.ts";
|
||||
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
|
||||
import { clampText } from "../../lib/format.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
|
||||
@@ -26,6 +27,11 @@ import {
|
||||
} from "../../lib/sessions/catalog-key.ts";
|
||||
import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
|
||||
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
import {
|
||||
taskSuggestionAcceptParams,
|
||||
type TaskSuggestionAcceptMode,
|
||||
} from "../../lib/task-suggestion-acceptance.ts";
|
||||
import { discoverCloudProfiles } from "../new-session/cloud-profile-discovery.ts";
|
||||
import { catalogMessageId } from "./catalog-message-id.ts";
|
||||
import { refreshChatAvatar } from "./chat-avatar.ts";
|
||||
import {
|
||||
@@ -68,6 +74,35 @@ import {
|
||||
import { scheduleChatScroll } from "./scroll.ts";
|
||||
|
||||
export abstract class ChatPaneSession extends ChatPaneSharing {
|
||||
protected async ensureTaskSuggestionCloudProfiles(): Promise<void> {
|
||||
const scope = this.captureConnectionScope();
|
||||
if (
|
||||
!scope ||
|
||||
this.taskSuggestions.length === 0 ||
|
||||
this.taskSuggestionCloudProfileGeneration === scope.generation ||
|
||||
!hasOperatorAdminAccess(scope.context.gateway.snapshot.hello?.auth ?? null) ||
|
||||
isGatewayMethodAdvertised(scope.context.gateway.snapshot, "taskSuggestions.accept") !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Profile metadata is connection-stable. Mark the generation before the
|
||||
// request so repeated renders cannot turn this optional affordance into polling.
|
||||
this.taskSuggestionCloudProfileGeneration = scope.generation;
|
||||
if (isGatewayMethodAdvertised(scope.context.gateway.snapshot, "environments.list") !== true) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profiles = await discoverCloudProfiles(scope.client, true);
|
||||
if (!this.isConnectionScopeCurrent(scope)) {
|
||||
return;
|
||||
}
|
||||
this.taskSuggestionCloudProfiles = profiles.map((profile) => ({ id: profile.id }));
|
||||
this.requestUpdate();
|
||||
} catch {
|
||||
// Cloud is optional; a failed one-shot discovery leaves the disabled hint.
|
||||
}
|
||||
}
|
||||
|
||||
protected async refreshTaskSuggestions(): Promise<void> {
|
||||
const requestVersion = ++this.taskSuggestionsRequestVersion;
|
||||
const scope = this.captureConnectionScope();
|
||||
@@ -208,8 +243,11 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
|
||||
void this.refreshTaskSuggestions();
|
||||
}
|
||||
|
||||
protected readonly acceptTaskSuggestion = (suggestion: TaskSuggestion): Promise<void> =>
|
||||
this.resolveTaskSuggestion(suggestion, "accept");
|
||||
protected readonly acceptTaskSuggestion = (
|
||||
suggestion: TaskSuggestion,
|
||||
mode: TaskSuggestionAcceptMode,
|
||||
cloudProfileId?: string,
|
||||
): Promise<void> => this.resolveTaskSuggestion(suggestion, "accept", mode, cloudProfileId);
|
||||
|
||||
protected readonly dismissTaskSuggestion = (suggestion: TaskSuggestion): Promise<void> =>
|
||||
this.resolveTaskSuggestion(suggestion, "dismiss");
|
||||
@@ -217,6 +255,8 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
|
||||
protected async resolveTaskSuggestion(
|
||||
suggestion: TaskSuggestion,
|
||||
action: "accept" | "dismiss",
|
||||
mode: TaskSuggestionAcceptMode = "worktree",
|
||||
cloudProfileId?: string,
|
||||
): Promise<void> {
|
||||
const scope = this.captureConnectionScope();
|
||||
if (
|
||||
@@ -236,16 +276,22 @@ export abstract class ChatPaneSession extends ChatPaneSharing {
|
||||
this.taskSuggestionBusyIds.add(suggestion.id);
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const result = await scope.client.request<TaskSuggestionsAcceptResult>(
|
||||
action === "accept" ? "taskSuggestions.accept" : "taskSuggestions.dismiss",
|
||||
{ taskId: suggestion.id },
|
||||
);
|
||||
let acceptedKey: string | undefined;
|
||||
if (action === "accept") {
|
||||
const result = await scope.client.request<TaskSuggestionsAcceptResult>(
|
||||
"taskSuggestions.accept",
|
||||
taskSuggestionAcceptParams(suggestion.id, mode, cloudProfileId),
|
||||
);
|
||||
acceptedKey = result.key;
|
||||
} else {
|
||||
await scope.client.request("taskSuggestions.dismiss", { taskId: suggestion.id });
|
||||
}
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
this.taskSuggestions = this.taskSuggestions.filter((item) => item.id !== suggestion.id);
|
||||
if (action === "accept") {
|
||||
this.onPaneSessionChange?.(this.paneId, result.key);
|
||||
if (acceptedKey && mode !== "session") {
|
||||
this.onPaneSessionChange?.(this.paneId, acceptedKey);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createInitialUserMessageHandoff } from "../../app/initial-user-message-
|
||||
import type { CatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import "./chat-pane.ts";
|
||||
import type { TaskSuggestionAcceptMode } from "../../lib/task-suggestion-acceptance.ts";
|
||||
import { attachChatRealtimeActions, createInitialChatRealtimeState } from "./chat-realtime.ts";
|
||||
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||
import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts";
|
||||
@@ -40,7 +41,11 @@ export type TestChatPane = HTMLElement & {
|
||||
restoreArchivedSession: (sessionKey: string) => Promise<void>;
|
||||
disconnectedCallback: () => void;
|
||||
discardBrowserAnnotations?: () => void;
|
||||
acceptTaskSuggestion: (suggestion: TaskSuggestion) => Promise<void>;
|
||||
acceptTaskSuggestion: (
|
||||
suggestion: TaskSuggestion,
|
||||
mode: TaskSuggestionAcceptMode,
|
||||
cloudProfileId?: string,
|
||||
) => Promise<void>;
|
||||
handleDocumentKeydown: (event: KeyboardEvent) => void;
|
||||
handleTaskSuggestionEvent: (event: TaskSuggestionEvent) => void;
|
||||
refreshTaskSuggestions: () => Promise<void>;
|
||||
|
||||
@@ -1044,7 +1044,7 @@ describe("chat pane task suggestion lifecycle", () => {
|
||||
const navigate = vi.fn();
|
||||
pane.onPaneSessionChange = navigate;
|
||||
|
||||
const pending = pane.acceptTaskSuggestion(suggestion);
|
||||
const pending = pane.acceptTaskSuggestion(suggestion, "worktree");
|
||||
pane.handleTaskSuggestionEvent({
|
||||
action: "resolved",
|
||||
taskId: suggestion.id,
|
||||
@@ -1066,7 +1066,7 @@ describe("chat pane task suggestion lifecycle", () => {
|
||||
const navigate = vi.fn();
|
||||
pane.onPaneSessionChange = navigate;
|
||||
|
||||
const pending = pane.acceptTaskSuggestion(suggestion);
|
||||
const pending = pane.acceptTaskSuggestion(suggestion, "worktree");
|
||||
pane.connectionGeneration += 1;
|
||||
accepted.resolve({ taskId: suggestion.id, key: "agent:main:stale" });
|
||||
|
||||
@@ -1074,6 +1074,22 @@ describe("chat pane task suggestion lifecycle", () => {
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps session-mode acceptance in the source pane", async () => {
|
||||
const request = vi.fn().mockResolvedValue({ taskId: suggestion.id, key: "agent:main:task" });
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
||||
const navigate = vi.fn();
|
||||
pane.onPaneSessionChange = navigate;
|
||||
|
||||
await pane.acceptTaskSuggestion(suggestion, "session");
|
||||
|
||||
expect(request).toHaveBeenCalledWith("taskSuggestions.accept", {
|
||||
taskId: suggestion.id,
|
||||
mode: "session",
|
||||
});
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a list response after a same-client reconnect", async () => {
|
||||
const listed = createDeferred<TaskSuggestionsListResult>();
|
||||
const client = {
|
||||
|
||||
@@ -10,104 +10,166 @@ const suggestion: TaskSuggestion = {
|
||||
title: "Remove stale adapter",
|
||||
prompt: "Delete the stale adapter and update tests.",
|
||||
tldr: "The adapter is unreachable and adds maintenance cost.",
|
||||
cwd: "/repo",
|
||||
cwd: "/repo/project",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
function renderSuggestion(
|
||||
overrides: Partial<Parameters<typeof renderChatTaskSuggestions>[0]> = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
renderChatTaskSuggestions({
|
||||
suggestions: [suggestion],
|
||||
busyIds: new Set(),
|
||||
canAccept: true,
|
||||
canAcceptModes: true,
|
||||
canDismiss: true,
|
||||
cloudProfiles: [],
|
||||
onAccept,
|
||||
onDismiss,
|
||||
...overrides,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
return { container, onAccept, onDismiss };
|
||||
}
|
||||
|
||||
function selectMenuItem(container: HTMLElement, item: Element) {
|
||||
container.querySelector("wa-dropdown")?.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { item },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("chat task suggestions", () => {
|
||||
it("renders an actionable chip", () => {
|
||||
const container = document.createElement("div");
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
renderChatTaskSuggestions({
|
||||
suggestions: [suggestion],
|
||||
busyIds: new Set(),
|
||||
canAccept: true,
|
||||
canDismiss: true,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}),
|
||||
container,
|
||||
it("renders a compact card with collapsed instructions and worktree primary action", () => {
|
||||
const { container, onAccept, onDismiss } = renderSuggestion();
|
||||
|
||||
expect(container.querySelector(".task-suggestion__eyebrow")?.textContent).toContain(
|
||||
"Suggested task · in project",
|
||||
);
|
||||
expect(container.querySelector(".task-suggestion__eyebrow")?.getAttribute("title")).toBe(
|
||||
"/repo/project",
|
||||
);
|
||||
expect(container.querySelector(".task-suggestion__title")?.textContent).toContain(
|
||||
"Remove stale adapter",
|
||||
);
|
||||
expect(container.querySelector(".task-suggestion__summary")?.textContent).toContain(
|
||||
"The adapter is unreachable",
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Remove stale adapter");
|
||||
expect(container.textContent).toContain("The adapter is unreachable");
|
||||
expect(container.textContent).toContain("/repo");
|
||||
expect(container.textContent).toContain("Delete the stale adapter and update tests.");
|
||||
const details = container.querySelector<HTMLDetailsElement>(".task-suggestion__instructions");
|
||||
expect(details?.open).toBe(false);
|
||||
details?.querySelector("summary")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(details?.open).toBe(true);
|
||||
expect(details?.textContent).toContain("Delete the stale adapter and update tests.");
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".task-suggestion__start")?.click();
|
||||
container.querySelector<HTMLButtonElement>(".task-suggestion__dismiss")?.click();
|
||||
expect(onAccept).toHaveBeenCalledWith(suggestion);
|
||||
expect(onAccept).toHaveBeenCalledWith(suggestion, "worktree", undefined);
|
||||
expect(onDismiss).toHaveBeenCalledWith(suggestion);
|
||||
});
|
||||
|
||||
it("renders nothing when no task actions are permitted", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChatTaskSuggestions({
|
||||
suggestions: [suggestion],
|
||||
busyIds: new Set(),
|
||||
canAccept: false,
|
||||
canDismiss: false,
|
||||
onAccept: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
it("forwards local, session, and per-profile cloud menu actions", () => {
|
||||
const { container, onAccept } = renderSuggestion({
|
||||
cloudProfiles: [{ id: "build" }, { id: "review" }],
|
||||
});
|
||||
const local = container.querySelector('wa-dropdown-item[value="local"]');
|
||||
const session = container.querySelector('wa-dropdown-item[value="session"]');
|
||||
const cloud = [...container.querySelectorAll('wa-dropdown-item[value="cloud"]')];
|
||||
|
||||
expect(cloud.map((item) => item.textContent?.trim())).toEqual([
|
||||
"Send to cloud · build",
|
||||
"Send to cloud · review",
|
||||
]);
|
||||
expect(local).not.toBeNull();
|
||||
expect(session).not.toBeNull();
|
||||
selectMenuItem(container, local!);
|
||||
selectMenuItem(container, session!);
|
||||
selectMenuItem(container, cloud[0]!);
|
||||
selectMenuItem(container, cloud[1]!);
|
||||
|
||||
expect(onAccept.mock.calls).toEqual([
|
||||
[suggestion, "local", undefined],
|
||||
[suggestion, "session", undefined],
|
||||
[suggestion, "cloud", "build"],
|
||||
[suggestion, "cloud", "review"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a worktree-only action when acceptance modes are not advertised", () => {
|
||||
const { container, onAccept } = renderSuggestion({ canAcceptModes: false });
|
||||
|
||||
expect(container.querySelector(".task-suggestion__start")).not.toBeNull();
|
||||
expect(container.querySelector(".task-suggestion__menu")).toBeNull();
|
||||
expect(container.querySelector(".task-suggestion__menu-trigger")).toBeNull();
|
||||
|
||||
container.querySelector<HTMLButtonElement>(".task-suggestion__start")?.click();
|
||||
expect(onAccept).toHaveBeenCalledWith(suggestion, "worktree", undefined);
|
||||
});
|
||||
|
||||
it("uses a generic single-cloud label and a disabled hint when none are configured", () => {
|
||||
const single = renderSuggestion({ cloudProfiles: [{ id: "build" }] }).container;
|
||||
expect(single.querySelector('wa-dropdown-item[value="cloud"]')?.textContent?.trim()).toBe(
|
||||
"Send to cloud",
|
||||
);
|
||||
|
||||
const none = renderSuggestion().container;
|
||||
const item = none.querySelector('wa-dropdown-item[value="cloud"]');
|
||||
expect(item?.hasAttribute("disabled")).toBe(true);
|
||||
expect(item?.getAttribute("title")).toBe("No cloud environment configured");
|
||||
});
|
||||
|
||||
it("renders nothing when no task actions are permitted", () => {
|
||||
const { container } = renderSuggestion({ canAccept: false, canDismiss: false });
|
||||
expect(container.querySelector(".task-suggestions")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows dismissal while requiring admin access to start", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChatTaskSuggestions({
|
||||
suggestions: [suggestion],
|
||||
busyIds: new Set(),
|
||||
canAccept: false,
|
||||
canDismiss: true,
|
||||
onAccept: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const { container } = renderSuggestion({ canAccept: false, canDismiss: true });
|
||||
|
||||
const start = container.querySelector<HTMLButtonElement>(".task-suggestion__start");
|
||||
expect(start?.disabled).toBe(true);
|
||||
expect(start?.title).toBe(
|
||||
"Administrator access is required to create a worktree from this project.",
|
||||
);
|
||||
expect(start?.title).toBe("Administrator access is required to start suggested tasks.");
|
||||
expect(container.querySelector(".task-suggestion__dismiss")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("strips bidi controls from every displayed field", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChatTaskSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
...suggestion,
|
||||
title: "safe\u202eevil",
|
||||
tldr: "why\u200f now",
|
||||
cwd: "/repo/\u2066project",
|
||||
prompt: "run\u202d exactly",
|
||||
},
|
||||
],
|
||||
busyIds: new Set(),
|
||||
canAccept: true,
|
||||
canDismiss: true,
|
||||
onAccept: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
const rawProfileId = "build\u202eprofile";
|
||||
const { container, onAccept } = renderSuggestion({
|
||||
suggestions: [
|
||||
{
|
||||
...suggestion,
|
||||
title: "safe\u202eevil",
|
||||
tldr: "why\u200f now",
|
||||
cwd: "/repo/\u2066project",
|
||||
prompt: "run\u202d exactly",
|
||||
},
|
||||
],
|
||||
cloudProfiles: [{ id: rawProfileId }, { id: "review" }],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("safeevil");
|
||||
expect(container.textContent).toContain("why now");
|
||||
expect(container.textContent).toContain("/repo/project");
|
||||
expect(container.textContent).toContain("run exactly");
|
||||
expect(container.textContent).toContain("buildprofile");
|
||||
expect(container.textContent).not.toMatch(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u);
|
||||
expect(container.querySelector(".task-suggestion__eyebrow")?.getAttribute("title")).toBe(
|
||||
"/repo/project",
|
||||
);
|
||||
|
||||
const cloud = container.querySelector("wa-dropdown-item[data-cloud-profile]");
|
||||
expect(cloud).not.toBeNull();
|
||||
selectMenuItem(container, cloud!);
|
||||
expect(onAccept).toHaveBeenCalledWith(expect.anything(), "cloud", rawProfileId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,9 +254,11 @@ export type ChatProps = {
|
||||
backgroundTasks?: BackgroundTasksProps;
|
||||
taskSuggestions?: TaskSuggestion[];
|
||||
taskSuggestionBusyIds?: ReadonlySet<string>;
|
||||
taskSuggestionCloudProfiles?: Array<{ id: string }>;
|
||||
canAcceptTaskSuggestions?: boolean;
|
||||
canAcceptTaskSuggestionModes?: boolean;
|
||||
canDismissTaskSuggestions?: boolean;
|
||||
onAcceptTaskSuggestion?: (suggestion: TaskSuggestion) => void;
|
||||
onAcceptTaskSuggestion?: Parameters<typeof renderChatTaskSuggestions>[0]["onAccept"];
|
||||
onDismissTaskSuggestion?: (suggestion: TaskSuggestion) => void;
|
||||
sessionSuggestions?: readonly SessionSuggestion[];
|
||||
sessionSuggestionRole?: SessionSharingRole;
|
||||
@@ -644,9 +646,12 @@ export function renderChat(props: ChatProps) {
|
||||
${renderChatTaskSuggestions({
|
||||
suggestions: props.taskSuggestions ?? [],
|
||||
busyIds: props.taskSuggestionBusyIds ?? new Set(),
|
||||
cloudProfiles: props.taskSuggestionCloudProfiles ?? [],
|
||||
canAccept: props.canAcceptTaskSuggestions === true,
|
||||
canAcceptModes: props.canAcceptTaskSuggestionModes === true,
|
||||
canDismiss: props.canDismissTaskSuggestions === true,
|
||||
onAccept: (suggestion) => props.onAcceptTaskSuggestion?.(suggestion),
|
||||
onAccept: (suggestion, mode, cloudProfileId) =>
|
||||
props.onAcceptTaskSuggestion?.(suggestion, mode, cloudProfileId),
|
||||
onDismiss: (suggestion) => props.onDismissTaskSuggestion?.(suggestion),
|
||||
})}
|
||||
${renderChatPullRequests({
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { TaskSuggestion } from "../../../../../packages/gateway-protocol/src/index.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import "../../../components/web-awesome.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { repoName } from "../../../lib/session-display.ts";
|
||||
import type { TaskSuggestionAcceptMode } from "../../../lib/task-suggestion-acceptance.ts";
|
||||
|
||||
type TaskSuggestionCloudProfile = { id: string };
|
||||
|
||||
// Mirrors the TUI sanitizer to prevent directionality spoofing. This stays local
|
||||
// because the Control UI cannot import core src/ modules.
|
||||
@@ -15,8 +20,14 @@ export function renderChatTaskSuggestions(props: {
|
||||
busyIds: ReadonlySet<string>;
|
||||
canAccept: boolean;
|
||||
canDismiss: boolean;
|
||||
onAccept: (suggestion: TaskSuggestion) => void;
|
||||
cloudProfiles: TaskSuggestionCloudProfile[];
|
||||
onAccept: (
|
||||
suggestion: TaskSuggestion,
|
||||
mode: TaskSuggestionAcceptMode,
|
||||
cloudProfileId?: string,
|
||||
) => void;
|
||||
onDismiss: (suggestion: TaskSuggestion) => void;
|
||||
canAcceptModes: boolean;
|
||||
}) {
|
||||
if (props.suggestions.length === 0 || (!props.canAccept && !props.canDismiss)) {
|
||||
return nothing;
|
||||
@@ -29,50 +40,130 @@ export function renderChatTaskSuggestions(props: {
|
||||
const tldr = sanitizeTaskSuggestionText(suggestion.tldr);
|
||||
const cwd = sanitizeTaskSuggestionText(suggestion.cwd);
|
||||
const prompt = sanitizeTaskSuggestionText(suggestion.prompt);
|
||||
const repo = sanitizeTaskSuggestionText(repoName(cwd));
|
||||
const cloudProfiles = props.cloudProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: sanitizeTaskSuggestionText(profile.id),
|
||||
}));
|
||||
const accept = (mode: TaskSuggestionAcceptMode, cloudProfileId?: string) => {
|
||||
if (!busy && props.canAccept) {
|
||||
props.onAccept(suggestion, mode, cloudProfileId);
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<article class="task-suggestion" data-task-id=${suggestion.id}>
|
||||
<div class="task-suggestion__icon" aria-hidden="true">${icons.spark}</div>
|
||||
<div class="task-suggestion__body">
|
||||
<div class="task-suggestion__eyebrow">${t("chat.taskSuggestions.eyebrow")}</div>
|
||||
<div class="task-suggestion__eyebrow" title=${cwd}>
|
||||
${t("chat.taskSuggestions.eyebrow", { repo })}
|
||||
</div>
|
||||
<div class="task-suggestion__title">${title}</div>
|
||||
<div class="task-suggestion__summary">${tldr}</div>
|
||||
<div class="task-suggestion__details">
|
||||
<div class="task-suggestion__detail">
|
||||
<span>${t("chat.taskSuggestions.project")}</span>
|
||||
<details class="chat-json-collapse task-suggestion__instructions">
|
||||
<summary class="chat-json-summary">
|
||||
${t("chat.taskSuggestions.showInstructions")}
|
||||
</summary>
|
||||
<div class="task-suggestion__instruction-body">
|
||||
<code>${cwd}</code>
|
||||
</div>
|
||||
<div class="task-suggestion__detail task-suggestion__detail--instructions">
|
||||
<span>${t("chat.taskSuggestions.instructions")}</span>
|
||||
<pre>${prompt}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
${props.canDismiss
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--ghost btn--icon task-suggestion__dismiss"
|
||||
type="button"
|
||||
?disabled=${busy}
|
||||
aria-label=${t("chat.taskSuggestions.dismiss", { title })}
|
||||
@click=${() => props.onDismiss(suggestion)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<div class="task-suggestion__actions">
|
||||
<button
|
||||
class="btn btn--primary task-suggestion__start"
|
||||
type="button"
|
||||
?disabled=${busy || !props.canAccept}
|
||||
title=${props.canAccept ? "" : t("chat.taskSuggestions.adminRequired")}
|
||||
@click=${() => props.onAccept(suggestion)}
|
||||
>
|
||||
${icons.play}
|
||||
${busy ? t("chat.taskSuggestions.starting") : t("chat.taskSuggestions.start")}
|
||||
</button>
|
||||
${props.canDismiss
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--ghost btn--icon task-suggestion__dismiss"
|
||||
type="button"
|
||||
?disabled=${busy}
|
||||
aria-label=${t("chat.taskSuggestions.dismiss", {
|
||||
title,
|
||||
})}
|
||||
@click=${() => props.onDismiss(suggestion)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<div class="task-suggestion__split">
|
||||
<button
|
||||
class="btn primary btn--primary task-suggestion__start"
|
||||
type="button"
|
||||
?disabled=${busy || !props.canAccept}
|
||||
title=${props.canAccept ? "" : t("chat.taskSuggestions.adminRequired")}
|
||||
@click=${() => accept("worktree")}
|
||||
>
|
||||
${icons.play}
|
||||
${busy
|
||||
? t("chat.taskSuggestions.starting")
|
||||
: t("chat.taskSuggestions.startWorktree")}
|
||||
</button>
|
||||
${props.canAcceptModes
|
||||
? html`
|
||||
<wa-dropdown
|
||||
class="task-suggestion__menu"
|
||||
placement="bottom-end"
|
||||
@wa-select=${(
|
||||
event: CustomEvent<{ item: HTMLElement & { value?: string } }>,
|
||||
) => {
|
||||
const item = event.detail.item;
|
||||
if (item.value === "local") {
|
||||
accept("local");
|
||||
} else if (item.value === "session") {
|
||||
accept("session");
|
||||
} else if (item.value === "cloud") {
|
||||
const profileId = item.dataset.cloudProfile;
|
||||
if (profileId) {
|
||||
accept("cloud", profileId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="btn primary task-suggestion__menu-trigger"
|
||||
type="button"
|
||||
?disabled=${busy || !props.canAccept}
|
||||
title=${props.canAccept ? "" : t("chat.taskSuggestions.adminRequired")}
|
||||
aria-label=${t("chat.taskSuggestions.moreActions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
${icons.chevronDown}
|
||||
</button>
|
||||
<wa-dropdown-item value="local" ?disabled=${busy || !props.canAccept}>
|
||||
${t("chat.taskSuggestions.startLocal")}
|
||||
</wa-dropdown-item>
|
||||
${cloudProfiles.length === 0
|
||||
? html`
|
||||
<wa-dropdown-item
|
||||
value="cloud"
|
||||
disabled
|
||||
title=${t("chat.taskSuggestions.noCloudConfigured")}
|
||||
>
|
||||
${t("chat.taskSuggestions.startCloudGeneric")}
|
||||
</wa-dropdown-item>
|
||||
`
|
||||
: cloudProfiles.map(
|
||||
(profile) => html`
|
||||
<wa-dropdown-item
|
||||
value="cloud"
|
||||
data-cloud-profile=${profile.id}
|
||||
?disabled=${busy || !props.canAccept}
|
||||
>
|
||||
${cloudProfiles.length > 1
|
||||
? t("chat.taskSuggestions.startCloud", {
|
||||
profile: profile.label,
|
||||
})
|
||||
: t("chat.taskSuggestions.startCloudGeneric")}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
<wa-dropdown-item value="session" ?disabled=${busy || !props.canAccept}>
|
||||
${t("chat.taskSuggestions.fixInSession")}
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatRelativeTimestamp } from "../../lib/format.ts";
|
||||
import { shouldHandleNavigationClick } from "../../lib/navigation-click.ts";
|
||||
import { repoName } from "../../lib/session-display.ts";
|
||||
import {
|
||||
resolveSessionPreferredFaceForKey,
|
||||
sessionNavigationTarget,
|
||||
@@ -38,10 +39,6 @@ type WorktreeBranchesResult = {
|
||||
headBranch?: string;
|
||||
};
|
||||
|
||||
function repoName(repoRoot: string): string {
|
||||
return repoRoot.split(/[\\/]/).findLast(Boolean) ?? repoRoot;
|
||||
}
|
||||
|
||||
class WorktreesPage extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
private context!: ApplicationContext;
|
||||
|
||||
@@ -1584,8 +1584,10 @@ openclaw-chat-video-player {
|
||||
}
|
||||
|
||||
.task-suggestion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
@@ -1597,7 +1599,6 @@ openclaw-chat-video-player {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: var(--accent);
|
||||
@@ -1612,7 +1613,6 @@ openclaw-chat-video-player {
|
||||
|
||||
.task-suggestion__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-suggestion__eyebrow {
|
||||
@@ -1630,36 +1630,28 @@ openclaw-chat-video-player {
|
||||
}
|
||||
|
||||
.task-suggestion__summary {
|
||||
display: -webkit-box;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.task-suggestion__details {
|
||||
.task-suggestion__instructions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-suggestion__instruction-body {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 10px;
|
||||
gap: 6px;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.task-suggestion__detail {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.task-suggestion__detail > span {
|
||||
padding-top: 3px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-suggestion__detail code,
|
||||
.task-suggestion__detail pre {
|
||||
.task-suggestion__instruction-body code,
|
||||
.task-suggestion__instruction-body pre {
|
||||
min-width: 0;
|
||||
max-height: 9rem;
|
||||
margin: 0;
|
||||
@@ -1675,33 +1667,82 @@ openclaw-chat-video-player {
|
||||
}
|
||||
|
||||
.task-suggestion__actions {
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.task-suggestion__split {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-suggestion__start {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-color: var(--accent);
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-suggestion__start:only-child {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.task-suggestion__menu-trigger {
|
||||
min-width: 34px;
|
||||
height: 100%;
|
||||
margin-left: -1px;
|
||||
padding: 8px;
|
||||
border-color: var(--accent);
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.task-suggestion__menu-trigger svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.task-suggestion__menu[open] .task-suggestion__menu-trigger svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.task-suggestion__menu::part(menu) {
|
||||
min-width: 190px;
|
||||
max-width: min(320px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.task-suggestion__dismiss {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-suggestion {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.task-suggestion__actions {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.task-suggestion__detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 3px;
|
||||
.task-suggestion__dismiss {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.task-suggestion:has(.task-suggestion__dismiss) .task-suggestion__body {
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user