返回 DeepSeek-Reasonix
inbox_app.go
根目录 / desktop / inbox_app.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "strings"
7
8 "reasonix/internal/control"
9 "reasonix/internal/sessioninbox"
10 )
11
12 const inboxBridgeErrorPrefix = "reasonix_error:"
13
14 type inboxCodedError struct {
15 code string
16 cause error
17 }
18
19 func (e *inboxCodedError) Error() string { return inboxBridgeErrorPrefix + e.code }
20 func (e *inboxCodedError) Unwrap() error { return e.cause }
21
22 // inboxBridgeError keeps backend errors machine-stable across the desktop bridge.
23 // The frontend translates known product states at display time; unknown errors
24 // stay untouched so useful diagnostic details are not discarded.
25 func inboxBridgeError(err error) error {
26 if err == nil {
27 return nil
28 }
29 var imageFailures control.ImageReferenceFailures
30 if errors.As(err, &imageFailures) {
31 return &inboxCodedError{code: "image_attachment_unreadable", cause: err}
32 }
33 known := []struct {
34 target error
35 code string
36 }{
37 {control.ErrInboxSessionChanged, "inbox_not_submitted"},
38 {sessioninbox.ErrCapacityItems, "inbox_capacity_items"},
39 {sessioninbox.ErrCapacityBytes, "inbox_capacity_bytes"},
40 {sessioninbox.ErrItemTooLarge, "inbox_item_too_large"},
41 {sessioninbox.ErrNotFound, "inbox_item_not_found"},
42 {sessioninbox.ErrInvalidState, "inbox_invalid_state"},
43 {sessioninbox.ErrSchemaReadonly, "inbox_schema_readonly"},
44 {sessioninbox.ErrClosed, "inbox_closed"},
45 {sessioninbox.ErrEmpty, "inbox_empty"},
46 {sessioninbox.ErrPaused, "inbox_paused"},
47 {sessioninbox.ErrIdempotencyConflict, "inbox_idempotency_conflict"},
48 }
49 for _, item := range known {
50 if errors.Is(err, item.target) {
51 return &inboxCodedError{code: item.code, cause: err}
52 }
53 }
54 switch {
55 case err.Error() == "channel session is read-only":
56 return &inboxCodedError{code: "channel_read_only", cause: err}
57 case err.Error() == "workspace is still starting":
58 return &inboxCodedError{code: "workspace_starting", cause: err}
59 case strings.HasPrefix(err.Error(), "workspace failed to start:"):
60 return &inboxCodedError{code: "workspace_start_failed", cause: err}
61 default:
62 return err
63 }
64 }
65
66 // InboxItemView is the bridge-facing metadata row (never full body).
67 type InboxItemView struct {
68 ID string `json:"id"`
69 Intent string `json:"intent"`
70 State string `json:"state"`
71 Preview string `json:"preview"`
72 ByteSize int64 `json:"byteSize"`
73 Source string `json:"source,omitempty"`
74 BlockReason string `json:"blockReason,omitempty"`
75 CreatedAt string `json:"createdAt,omitempty"`
76 Position int `json:"position"`
77 }
78
79 // InboxSnapshotView is the bridge-facing queue snapshot.
80 type InboxSnapshotView struct {
81 Revision int64 `json:"revision"`
82 Paused bool `json:"paused"`
83 Recovered bool `json:"recovered"`
84 RecoveredN int `json:"recoveredCount,omitempty"`
85 SessionPath string `json:"sessionPath,omitempty"`
86 Items []InboxItemView `json:"items"`
87 ItemsCount int `json:"itemsCount"`
88 Bytes int64 `json:"bytes"`
89 MaxItems int `json:"maxItems"`
90 MaxBytes int64 `json:"maxBytes"`
91 }
92
93 // InboxReceiptView is returned after durable enqueue/steer.
94 type InboxReceiptView struct {
95 ItemID string `json:"itemId"`
96 Disposition string `json:"disposition"`
97 Position int `json:"position"`
98 Paused bool `json:"paused"`
99 Idempotent bool `json:"idempotent,omitempty"`
100 Error string `json:"error,omitempty"`
101 }
102
103 // InboxCancelResultView is the backend-confirmed withdrawal receipt. The
104 // frontend must restore only these durable item IDs into the draft.
105 type InboxCancelResultView struct {
106 DiscardedItemIDs []string `json:"discardedItemIds"`
107 Warning string `json:"warning,omitempty"`
108 }
109
110 type inboxChangedView struct {
111 TabID string `json:"tabId"`
112 SessionPath string `json:"sessionPath,omitempty"`
113 Revision int64 `json:"revision,omitempty"`
114 }
115
116 // InboxEnvelopeView is the full body for the editor (fetched by id only).
117 type InboxEnvelopeView struct {
118 ID string `json:"id"`
119 DisplayText string `json:"displayText"`
120 RawText string `json:"rawText"`
121 SubmitText string `json:"submitText"`
122 }
123
124 func inboxSnapshotView(snap sessioninbox.InboxSnapshot) InboxSnapshotView {
125 items := make([]InboxItemView, 0, len(snap.Items))
126 for i, it := range snap.Items {
127 items = append(items, InboxItemView{
128 ID: it.ID,
129 Intent: string(it.Intent),
130 State: string(it.State),
131 Preview: it.Preview,
132 ByteSize: it.ByteSize,
133 Source: it.Source,
134 BlockReason: it.BlockReason,
135 CreatedAt: it.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
136 Position: i + 1,
137 })
138 }
139 return InboxSnapshotView{
140 Revision: snap.Revision,
141 Paused: snap.Paused,
142 Recovered: snap.Recovered,
143 RecoveredN: snap.RecoveredN,
144 SessionPath: snap.SessionPath,
145 Items: items,
146 ItemsCount: len(items),
147 Bytes: snap.Capacity.Bytes,
148 MaxItems: snap.Capacity.MaxItems,
149 MaxBytes: snap.Capacity.MaxBytes,
150 }
151 }
152
153 func (a *App) inboxCtrl(tabID string) (control.SessionAPI, error) {
154 tab, ctrl := a.tabAndCtrlByID(tabID)
155 if a.tabIsReadOnly(tab) {
156 return nil, inboxBridgeError(readOnlyChannelErr())
157 }
158 if ctrl == nil {
159 return nil, inboxBridgeError(a.workspaceNotReadyErr(tab))
160 }
161 return ctrl, nil
162 }
163
164 // InboxSnapshot returns durable inbox metadata for a tab (no bodies).
165 func (a *App) InboxSnapshot(tabID string) (InboxSnapshotView, error) {
166 if a.isRemoteTab(tabID) {
167 return a.remoteInboxSnapshot(tabID)
168 }
169 ctrl, err := a.inboxCtrl(tabID)
170 if err != nil {
171 return InboxSnapshotView{}, err
172 }
173 return inboxSnapshotView(ctrl.InboxSnapshot()), nil
174 }
175
176 // EnqueueInboxFollowup durably queues a follow-up for the tab.
177 func (a *App) EnqueueInboxFollowup(tabID, display, submit, idempotency string) (InboxReceiptView, error) {
178 return a.enqueueInbox(tabID, sessioninbox.IntentFollowup, display, submit, nil, idempotency, false)
179 }
180
181 // EnqueueInboxFollowupWithInvocations preserves rich-composer Skill/Subagent
182 // entities in the durable envelope instead of degrading them to slash text.
183 func (a *App) EnqueueInboxFollowupWithInvocations(tabID, display, submit string, invocations []InvocationRequest, idempotency string) (InboxReceiptView, error) {
184 return a.enqueueInbox(tabID, sessioninbox.IntentFollowup, display, submit, invocations, idempotency, false)
185 }
186
187 // EnqueueInboxSteer durably queues and attempts mid-turn steer.
188 func (a *App) EnqueueInboxSteer(tabID, display, submit, idempotency string) (InboxReceiptView, error) {
189 return a.enqueueInbox(tabID, sessioninbox.IntentSteer, display, submit, nil, idempotency, true)
190 }
191
192 // EnqueueInboxSteerForTurn durably records guidance while ensuring its
193 // mid-turn injection is fenced to the exact turn observed by the frontend.
194 // A raced completion keeps the item as a follow-up instead of steering the
195 // replacement turn.
196 func (a *App) EnqueueInboxSteerForTurn(tabID, turnID, display, submit, idempotency string) (InboxReceiptView, error) {
197 turnID = strings.TrimSpace(turnID)
198 if turnID == "" {
199 return InboxReceiptView{}, fmt.Errorf("turnId is required")
200 }
201 ctrl, err := a.inboxCtrl(tabID)
202 if err != nil {
203 return InboxReceiptView{}, err
204 }
205 status := ctrl.RuntimeStatus()
206 if status.TurnID != turnID || !status.Running {
207 return InboxReceiptView{}, fmt.Errorf("turn %q is not the active turn for tab %q", turnID, tabID)
208 }
209 return a.enqueueInboxWithController(tabID, ctrl, sessioninbox.IntentSteer, display, submit, nil, idempotency, true, turnID, "")
210 }
211
212 // SteerInboxItem attempts to apply an existing durable queue item to the
213 // current turn. It never creates a second entry for the same instruction.
214 func (a *App) SteerInboxItem(tabID, itemID string) (InboxReceiptView, error) {
215 ctrl, err := a.inboxCtrl(tabID)
216 if err != nil {
217 return InboxReceiptView{}, err
218 }
219 rec, err := ctrl.TrySteerInboxItem(strings.TrimSpace(itemID))
220 if err != nil {
221 err = inboxBridgeError(err)
222 return InboxReceiptView{Error: err.Error()}, err
223 }
224 a.emitInboxChanged(tabID)
225 return InboxReceiptView{
226 ItemID: rec.ItemID,
227 Disposition: string(rec.Disposition),
228 Position: rec.Position,
229 Paused: rec.Paused,
230 Idempotent: rec.Idempotent,
231 }, nil
232 }
233
234 // SteerInboxItemForTurn is the exact-turn counterpart for an existing durable
235 // guidance item.
236 func (a *App) SteerInboxItemForTurn(tabID, turnID, itemID string) (InboxReceiptView, error) {
237 turnID = strings.TrimSpace(turnID)
238 if turnID == "" {
239 return InboxReceiptView{}, fmt.Errorf("turnId is required")
240 }
241 ctrl, err := a.inboxCtrl(tabID)
242 if err != nil {
243 return InboxReceiptView{}, err
244 }
245 status := ctrl.RuntimeStatus()
246 if status.TurnID != turnID || !status.Running {
247 return InboxReceiptView{}, fmt.Errorf("turn %q is not the active turn for tab %q", turnID, tabID)
248 }
249 exact, ok := ctrl.(interface {
250 TrySteerInboxItemForTurn(string, string) (sessioninbox.InboxReceipt, error)
251 })
252 if !ok {
253 return InboxReceiptView{}, fmt.Errorf("exact-turn steer is unavailable")
254 }
255 rec, err := exact.TrySteerInboxItemForTurn(turnID, strings.TrimSpace(itemID))
256 if err != nil {
257 err = inboxBridgeError(err)
258 return InboxReceiptView{Error: err.Error()}, err
259 }
260 a.emitInboxChanged(tabID)
261 return InboxReceiptView{
262 ItemID: rec.ItemID, Disposition: string(rec.Disposition), Position: rec.Position,
263 Paused: rec.Paused, Idempotent: rec.Idempotent,
264 }, nil
265 }
266
267 // CancelTabWithInboxItems cancels the turn and atomically discards only the
268 // durable pending items currently shown by that tab's Composer.
269 func (a *App) CancelTabWithInboxItems(tabID string, itemIDs []string) error {
270 ctrl, err := a.inboxCtrl(tabID)
271 if err != nil {
272 return err
273 }
274 if err := ctrl.CancelWithInboxItems(itemIDs, "desktop"); err != nil {
275 return inboxBridgeError(err)
276 }
277 a.emitInboxChanged(tabID)
278 return nil
279 }
280
281 // CancelTabWithInboxItemsResult is the receipt-capable cancellation API. It is
282 // additive so older desktop frontends can continue using the legacy method.
283 func (a *App) CancelTabWithInboxItemsResult(tabID string, itemIDs []string) (InboxCancelResultView, error) {
284 view := InboxCancelResultView{DiscardedItemIDs: []string{}}
285 ctrl, err := a.inboxCtrl(tabID)
286 if err != nil {
287 return view, err
288 }
289 result, err := ctrl.CancelWithInboxItemsResult(itemIDs, "desktop")
290 if err != nil {
291 return view, inboxBridgeError(err)
292 }
293 view.DiscardedItemIDs = append(view.DiscardedItemIDs, result.DiscardedItemIDs...)
294 view.Warning = result.Warning
295 a.emitInboxChanged(tabID)
296 return view, nil
297 }
298
299 func (a *App) enqueueInbox(tabID string, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool) (InboxReceiptView, error) {
300 a.remoteTabMu.Lock()
301 remote := a.remoteTabs[tabID] != nil
302 a.remoteTabMu.Unlock()
303 if remote && !trySteer {
304 return a.enqueueRemoteFollowup(tabID, display, submit, invocations, idempotency)
305 }
306 ctrl, err := a.inboxCtrl(tabID)
307 if err != nil {
308 return InboxReceiptView{}, err
309 }
310 return a.enqueueInboxWithController(tabID, ctrl, intent, display, submit, invocations, idempotency, trySteer, "", "")
311 }
312
313 func (a *App) enqueueInboxWithController(tabID string, ctrl control.SessionAPI, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool, turnID, expectedPath string) (InboxReceiptView, error) {
314 if ensurer, ok := ctrl.(interface{ EnsureSessionPath() }); ok {
315 ensurer.EnsureSessionPath()
316 }
317 submit = strings.TrimSpace(submit)
318 display = strings.TrimSpace(display)
319 if submit == "" && len(invocations) == 0 {
320 submit = display
321 }
322 if display == "" {
323 display = submit
324 }
325 req := control.InboxRequest{
326 ExpectedSessionPath: expectedPath,
327 Intent: intent,
328 Display: display,
329 Raw: submit,
330 Submit: submit,
331 Source: "desktop",
332 Idempotency: strings.TrimSpace(idempotency),
333 Invocations: controlInvocationRequests(invocations),
334 }
335 var (
336 rec sessioninbox.InboxReceipt
337 err error
338 )
339 if trySteer {
340 if turnID != "" {
341 exact, ok := ctrl.(interface {
342 TryEnqueueAndSteerForTurn(string, control.InboxRequest) (sessioninbox.InboxReceipt, error)
343 })
344 if !ok {
345 return InboxReceiptView{}, fmt.Errorf("exact-turn steer is unavailable")
346 }
347 rec, err = exact.TryEnqueueAndSteerForTurn(turnID, req)
348 } else {
349 rec, err = ctrl.TryEnqueueAndSteer(req)
350 }
351 } else {
352 rec, err = ctrl.TryEnqueueFollowup(req)
353 }
354 if err != nil {
355 err = inboxBridgeError(err)
356 return InboxReceiptView{Error: err.Error()}, err
357 }
358 a.emitInboxChanged(tabID)
359 return InboxReceiptView{
360 ItemID: rec.ItemID,
361 Disposition: string(rec.Disposition),
362 Position: rec.Position,
363 Paused: rec.Paused,
364 Idempotent: rec.Idempotent,
365 }, nil
366 }
367
368 // ReadInboxItem returns the full envelope for editing.
369 func (a *App) ReadInboxItem(tabID, id string) (InboxEnvelopeView, error) {
370 ctrl, err := a.inboxCtrl(tabID)
371 if err != nil {
372 return InboxEnvelopeView{}, err
373 }
374 meta, env, err := ctrl.ReadInboxItem(id)
375 if err != nil {
376 return InboxEnvelopeView{}, inboxBridgeError(err)
377 }
378 return InboxEnvelopeView{
379 ID: meta.ID,
380 DisplayText: env.DisplayText,
381 RawText: env.RawText,
382 SubmitText: env.SubmitText,
383 }, nil
384 }
385
386 // UpdateInboxItem rewrites a durable entry and re-freezes refs.
387 func (a *App) UpdateInboxItem(tabID, id, display, submit string) error {
388 ctrl, err := a.inboxCtrl(tabID)
389 if err != nil {
390 return err
391 }
392 if _, err := ctrl.UpdateInboxItem(id, display, submit, submit); err != nil {
393 return inboxBridgeError(err)
394 }
395 a.emitInboxChanged(tabID)
396 return nil
397 }
398
399 // DeleteInboxItem removes a durable entry.
400 func (a *App) DeleteInboxItem(tabID, id string) error {
401 ctrl, err := a.inboxCtrl(tabID)
402 if err != nil {
403 return err
404 }
405 if err := ctrl.DeleteInboxItem(id); err != nil {
406 return inboxBridgeError(err)
407 }
408 a.emitInboxChanged(tabID)
409 return nil
410 }
411
412 // MoveInboxItem reorders (toIndex is 0-based).
413 func (a *App) MoveInboxItem(tabID, id string, toIndex int) error {
414 ctrl, err := a.inboxCtrl(tabID)
415 if err != nil {
416 return err
417 }
418 if err := ctrl.MoveInboxItem(id, toIndex); err != nil {
419 return inboxBridgeError(err)
420 }
421 a.emitInboxChanged(tabID)
422 return nil
423 }
424
425 // SetInboxPaused pauses or resumes dispatch.
426 func (a *App) SetInboxPaused(tabID string, paused bool) error {
427 ctrl, err := a.inboxCtrl(tabID)
428 if err != nil {
429 return err
430 }
431 if err := ctrl.SetInboxPaused(paused); err != nil {
432 return inboxBridgeError(err)
433 }
434 a.emitInboxChanged(tabID)
435 return nil
436 }
437
438 // RetryInboxItem resets uncertain/blocked items to queued.
439 func (a *App) RetryInboxItem(tabID, id string) error {
440 ctrl, err := a.inboxCtrl(tabID)
441 if err != nil {
442 return err
443 }
444 if err := ctrl.RetryInboxItem(id); err != nil {
445 return inboxBridgeError(err)
446 }
447 a.emitInboxChanged(tabID)
448 return nil
449 }
450
451 // RefreshInboxReferences re-freezes @-refs for an item.
452 func (a *App) RefreshInboxItem(tabID, id string) error {
453 ctrl, err := a.inboxCtrl(tabID)
454 if err != nil {
455 return err
456 }
457 if err := ctrl.RefreshInboxReferences(id); err != nil {
458 return inboxBridgeError(err)
459 }
460 a.emitInboxChanged(tabID)
461 return nil
462 }
463
464 // SteerForTab still works for compatibility; prefer EnqueueInboxSteer so the
465 // guidance is durable before admission.
466 func (a *App) emitInboxChanged(tabID string) {
467 if a == nil || a.ctx == nil {
468 return
469 }
470 runtimeEventsEmitFallback(a.ctx, "InboxChanged", map[string]string{"tabId": tabID})
471 }
472
473 // ClearSessionConfirm checks for a non-empty inbox before clear.
474 func (a *App) InboxHasItems(tabID string) (bool, error) {
475 ctrl, err := a.inboxCtrl(tabID)
476 if err != nil {
477 return false, err
478 }
479 return len(ctrl.InboxSnapshot().Items) > 0, nil
480 }
481
481 lines GO