Skip to content
7 changes: 5 additions & 2 deletions docs/features/api-server/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ All endpoints are under the `/api` prefix.
| `GET` | `/api/sessions/:id` | Get a session by ID (messages, tokens, permissions) |
| `GET` | `/api/sessions/:id/status` | Lightweight runtime state (streaming, title, agent, tokens). Requires an attached runtime. |
| `GET` | `/api/sessions/:id/snapshot` | Full state in one call (stored fields + runtime state + `last_event_seq`) for gapless resync — see [Reconnecting without gaps](#reconnecting-without-gaps). |
| `GET` | `/api/sessions/:id/events` | Live session event stream (SSE) with sequence numbers and replay. Available for a run attached via [`--listen`](#listen). |
| `GET` | `/api/sessions/:id/events` | Live session event stream (SSE) with sequence numbers and replay. Available for a run attached via [`--listen`](#listen), or once a session has raised at least one out-of-band event (e.g. a background job's elicitation, answered via `POST .../elicitation`) that created a session-scoped event log on demand. |
| `DELETE` | `/api/sessions/:id` | Delete a session |
| `PATCH` | `/api/sessions/:id/title` | Update session title |
| `PATCH` | `/api/sessions/:id/permissions` | Update session permissions |
Expand Down Expand Up @@ -255,7 +255,10 @@ session's runtime events — `stream_started`, `agent_choice`, `tool_call`,
per-request stream returned by the agent-execution endpoint, it is
session-scoped and survives across turns, so a client can watch a session for
its whole lifetime. It is available for a run attached via
[`--listen`](#listen).
[`--listen`](#listen), and — since a session-scoped event log is created on
demand the first time a session raises an out-of-band event — for any
API-created session that has produced at least one (see the
[Sessions endpoint table](#sessions) above).

Each event carries a monotonic **sequence number** in the SSE `id:` field, and
the server buffers recent events. This makes the stream resumable:
Expand Down
17 changes: 17 additions & 0 deletions docs/guides/go-sdk/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,23 @@ if err := chat.Restart(); err != nil {

For advanced use (custom elicitation, raw event inspection), call `chat.Runtime()` to access the underlying `runtime.Runtime` directly.

> [!WARNING]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can it impact AP @simonferquel ?

> **Breaking change: `Runtime.ResumeElicitation` (#3584)**
>
> `Runtime.ResumeElicitation` gained an `elicitationID` parameter so responses can
> be correlated with a specific concurrent elicitation request (needed once
> multiple background jobs can be eliciting input at the same time). It is
> declared **variadic** (`elicitationID ...string`) specifically so existing
> *callers* of the 3-argument form keep compiling unchanged — `rt.ResumeElicitation(ctx, action, content)`
> still works and falls back to resolving the sole pending request.
>
> If you implement your own `runtime.Runtime` (rather than embedding
> `runtime.LocalRuntime`/`runtime.RemoteRuntime`), you do need to update your
> method's signature to match, and also add an `OnElicitationRequest(handler
> func(runtime.Event))` method (a no-op is fine if your runtime never raises
> elicitations) — both are required interface methods, matching the existing
> no-op-able pattern already used by `OnToolsChanged`/`OnBackgroundEvent`.

## Optional Provider Build Tags

By default docker-agent includes all four cloud providers (OpenAI, Anthropic, Google, Amazon Bedrock). When embedding docker-agent in your own binary you can compile out unneeded providers — together with their transitive SDK dependencies — to reduce binary size.
Expand Down
7 changes: 7 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ type DesktopTokenResponse struct {
type ResumeElicitationRequest struct {
Action string `json:"action"` // "accept", "decline", or "cancel"
Content map[string]any `json:"content"` // The submitted form data (only present when action is "accept")
// ElicitationID correlates this response with a specific concurrent
// elicitation request (see the elicitation_id field on the
// ElicitationRequestEvent stream event). Optional and additive: when
// empty, the server falls back to resolving the sole pending request,
// for backward compatibility with clients that predate per-request
// correlation (#3584).
ElicitationID string `json:"elicitation_id,omitempty"`
}

// SteerSessionRequest represents a request to inject user messages into a
Expand Down
20 changes: 17 additions & 3 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ func (a *App) Start(ctx context.Context) {
case <-ctx.Done():
}
})

// Forward elicitation requests raised anywhere in the runtime —
// including background-job (run_background_agent) sub-sessions whose
// RunStream has no live UI reading its own events channel — so they
// always reach the TUI as a dialog. This is the runtime's single,
// exactly-once delivery point for elicitation requests (#3584); the
// swap-based bridge is a separate best-effort path for remote/SSE
// consumers only and never also reaches this sink, so no App-side
// dedupe is needed here.
a.runtime.OnElicitationRequest(func(event runtime.Event) {
a.sendEvent(ctx, event)
})
})
}

Expand Down Expand Up @@ -933,9 +945,11 @@ func (a *App) TogglePause() (paused, supported bool) {
return p, true
}

// ResumeElicitation resumes an elicitation request with the given action and content
func (a *App) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error {
return a.runtime.ResumeElicitation(ctx, action, content)
// ResumeElicitation resumes an elicitation request with the given action and
// content. elicitationID is additive: pass "" to fall back to resolving the
// sole pending request (see runtime.Runtime.ResumeElicitation).
func (a *App) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID string) error {
return a.runtime.ResumeElicitation(ctx, action, content, elicitationID)
}

func (a *App) NewSession() {
Expand Down
111 changes: 110 additions & 1 deletion pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -58,7 +59,7 @@ func (m *mockRuntime) Run(ctx context.Context, sess *session.Session) ([]session
return nil, nil
}
func (m *mockRuntime) Resume(ctx context.Context, req runtime.ResumeRequest) {}
func (m *mockRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error {
func (m *mockRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID ...string) error {
return nil
}
func (m *mockRuntime) SessionStore() session.Store { return m.store }
Expand Down Expand Up @@ -107,6 +108,7 @@ func (m *mockRuntime) AvailableModels(context.Context) []runtime.ModelChoice { r
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
func (m *mockRuntime) OnToolsChanged(func(runtime.Event)) {}
func (m *mockRuntime) OnBackgroundEvent(func(runtime.Event)) {}
func (m *mockRuntime) OnElicitationRequest(func(runtime.Event)) {}

// Verify mockRuntime implements runtime.Runtime
var _ runtime.Runtime = (*mockRuntime)(nil)
Expand Down Expand Up @@ -217,6 +219,113 @@ func TestApp_Start_ForwardsBackgroundEvents(t *testing.T) {
}
}

// elicitationRequestMockRuntime captures the handler App.Start registers via
// OnElicitationRequest and records every ResumeElicitation call, so tests can
// drive the sink and assert on what App forwards back to the runtime.
type elicitationRequestMockRuntime struct {
mockRuntime

handler func(runtime.Event)

mu sync.Mutex
resumedIDs []string
resumedActions []tools.ElicitationAction
}

// firstOrEmpty returns the first element of ids, or "" when empty. Mirrors
// runtime.firstElicitationID for tests that record a variadic call's ID.
func firstOrEmpty(ids []string) string {
if len(ids) == 0 {
return ""
}
return ids[0]
}

func (m *elicitationRequestMockRuntime) OnElicitationRequest(handler func(runtime.Event)) {
m.handler = handler
}

func (m *elicitationRequestMockRuntime) ResumeElicitation(_ context.Context, action tools.ElicitationAction, _ map[string]any, elicitationID ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.resumedIDs = append(m.resumedIDs, firstOrEmpty(elicitationID))
m.resumedActions = append(m.resumedActions, action)
return nil
}

// TestApp_Start_ForwardsElicitationRequests verifies Start wires the
// runtime's OnElicitationRequest sink into the app's event stream, so
// background-job elicitations (which have no live channel of their own
// reaching the TUI) are surfaced (#3584).
func TestApp_Start_ForwardsElicitationRequests(t *testing.T) {
t.Parallel()

rt := &elicitationRequestMockRuntime{}
events := make(chan tea.Msg, 16)
app := &App{
runtime: rt,
session: session.New(),
events: events,
}

app.Start(t.Context())
require.NotNil(t, rt.handler, "Start must register the OnElicitationRequest handler")

ev := runtime.ElicitationRequest("need input", "form", nil, "", "eid-1", "", "sess-1", nil, "worker")
rt.handler(ev)

select {
case msg := <-events:
assert.Equal(t, ev, msg, "the elicitation request must reach the app's event stream unchanged")
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for the forwarded elicitation request")
}
}

// TestApp_SendEvent_DeliversElicitationWithoutDedupe pins the #3584 fix that
// removed the App-side ElicitationID dedupe: the runtime's
// OnElicitationRequest sink is now the single, exactly-once delivery point
// (elicitationHandler calls it directly, synchronously, and unconditionally,
// and runCollecting no longer re-forwards a bridge-observed copy), so
// sendEvent must forward every event unconditionally — including two
// distinct events that happen to share an ElicitationID, which a stateful
// dedupe would have incorrectly collapsed.
func TestApp_SendEvent_DeliversElicitationWithoutDedupe(t *testing.T) {
t.Parallel()

events := make(chan tea.Msg, 16)
app := &App{events: events}
ctx := t.Context()

ev := runtime.ElicitationRequest("need input", "form", nil, "", "eid-dup", "", "sess-1", nil, "worker")
app.sendEvent(ctx, ev)
require.Len(t, events, 1, "the sink's single delivery must reach the app's event stream")
<-events

// A second event that happens to carry the same ElicitationID (e.g. a
// canceled request's ID reused later) must still go through: nothing in
// the App layer keys off ElicitationID any more.
app.sendEvent(ctx, ev)
require.Len(t, events, 1, "sendEvent must not drop a delivery based on ElicitationID")
}

// TestApp_ResumeElicitation_ForwardsID verifies ResumeElicitation passes the
// elicitation ID through to the runtime unchanged. There is no dedupe state
// to clear any more (#3584): the runtime's per-request waiter registry is
// the sole source of truth for whether an ID is still answerable.
func TestApp_ResumeElicitation_ForwardsID(t *testing.T) {
t.Parallel()

rt := &elicitationRequestMockRuntime{}
app := &App{runtime: rt, events: make(chan tea.Msg, 16)}

require.NoError(t, app.ResumeElicitation(t.Context(), tools.ElicitationActionAccept, nil, "eid-clear"))

require.Len(t, rt.resumedIDs, 1)
assert.Equal(t, "eid-clear", rt.resumedIDs[0])
assert.Equal(t, tools.ElicitationActionAccept, rt.resumedActions[0])
}

// stubSnapshotController is a tiny SnapshotController used by the app
// tests to drive /undo without spinning up a real shadow-git
// repository. enabled gates SnapshotsEnabled(), and the (files, ok,
Expand Down
2 changes: 1 addition & 1 deletion pkg/chatserver/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func runAgentLoop(ctx context.Context, rt runtime.Runtime, sess *session.Session
case *runtime.ElicitationRequestEvent:
// Required: the runtime blocks until we respond, regardless
// of NonInteractive. Decline so the tool call fails fast.
_ = rt.ResumeElicitation(ctx, tools.ElicitationActionDecline, nil)
_ = rt.ResumeElicitation(ctx, tools.ElicitationActionDecline, nil, e.ElicitationID)
case *runtime.MaxIterationsReachedEvent:
// Defensive: in non-interactive mode the runtime already
// stops on its own and this Resume is dropped.
Expand Down
8 changes: 4 additions & 4 deletions pkg/cli/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
// still Reject (the hook said Ask, not Approve).
rt.Resume(ctx, runtime.ResumeReject(""))
case *runtime.ElicitationRequestEvent:
_ = rt.ResumeElicitation(ctx, "decline", nil)
_ = rt.ResumeElicitation(ctx, "decline", nil, e.ElicitationID)
case *runtime.MaxIterationsReachedEvent:
switch handleMaxIterationsAutoApprove(cfg.AutoApprove, &autoExtensions, e.MaxIterations) {
case maxIterContinue:
Expand Down Expand Up @@ -242,7 +242,7 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
serverURL, ok := e.Meta["docker-agent/server_url"].(string)
if !ok || serverURL == "" {
slog.WarnContext(ctx, "Skipping elicitation: missing or invalid server_url (non-interactive session?)")
_ = rt.ResumeElicitation(ctx, "decline", nil)
_ = rt.ResumeElicitation(ctx, "decline", nil, e.ElicitationID)
return nil
}

Expand All @@ -254,9 +254,9 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess

switch result {
case ConfirmationApprove:
_ = rt.ResumeElicitation(ctx, "accept", nil)
_ = rt.ResumeElicitation(ctx, "accept", nil, e.ElicitationID)
case ConfirmationReject:
_ = rt.ResumeElicitation(ctx, "decline", nil)
_ = rt.ResumeElicitation(ctx, "decline", nil, e.ElicitationID)
return errors.New("OAuth authorization rejected by user")
}
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/cli/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func (m *mockRuntime) Run(context.Context, *session.Session) ([]session.Message,
return nil, nil
}

func (m *mockRuntime) ResumeElicitation(_ context.Context, action tools.ElicitationAction, _ map[string]any) error {
func (m *mockRuntime) ResumeElicitation(_ context.Context, action tools.ElicitationAction, _ map[string]any, _ ...string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.elicitationDeclines++
Expand Down Expand Up @@ -110,6 +110,7 @@ func (m *mockRuntime) AvailableModels(context.Context) []runtime.ModelChoice
func (m *mockRuntime) SupportsModelSwitching() bool { return false }
func (m *mockRuntime) OnToolsChanged(func(runtime.Event)) {}
func (m *mockRuntime) OnBackgroundEvent(func(runtime.Event)) {}
func (m *mockRuntime) OnElicitationRequest(func(runtime.Event)) {}
func (m *mockRuntime) RegenerateTitle(context.Context, *session.Session, chan runtime.Event) {}

func (m *mockRuntime) Resume(_ context.Context, req runtime.ResumeRequest) {
Expand Down
4 changes: 2 additions & 2 deletions pkg/embeddedchat/embeddedchat.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ type ToolActivity struct {
type runtimeRunner interface {
RunStream(ctx context.Context, sess *session.Session) <-chan dagentruntime.Event
Resume(ctx context.Context, req dagentruntime.ResumeRequest)
ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any) error
ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID ...string) error
Close() error
}

Expand Down Expand Up @@ -303,7 +303,7 @@ func (s *Session) forwardEvents(ctx context.Context, events <-chan dagentruntime
// This headless wrapper has no built-in elicitation UI. Decline so the
// run cannot hang forever; embedders that need elicitation can consume
// RuntimeEvent directly by driving the runtime themselves.
_ = s.rt.ResumeElicitation(ctx, tools.ElicitationActionDecline, nil)
_ = s.rt.ResumeElicitation(ctx, tools.ElicitationActionDecline, nil, e.ElicitationID)
case *dagentruntime.MaxIterationsReachedEvent:
s.rt.Resume(ctx, dagentruntime.ResumeReject(""))
case *dagentruntime.ErrorEvent:
Expand Down
4 changes: 2 additions & 2 deletions pkg/embeddedchat/embeddedchat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (f *fakeRuntime) Resume(_ context.Context, req dagentruntime.ResumeRequest)
f.resumes = append(f.resumes, req)
}

func (f *fakeRuntime) ResumeElicitation(_ context.Context, action tools.ElicitationAction, _ map[string]any) error {
func (f *fakeRuntime) ResumeElicitation(_ context.Context, action tools.ElicitationAction, _ map[string]any, _ ...string) error {
f.elicitations = append(f.elicitations, action)
return nil
}
Expand Down Expand Up @@ -166,7 +166,7 @@ func TestSessionSendDeclinesElicitationAndRejectsMaxIterations(t *testing.T) {

out, err := s.Send(t.Context(), "hi")
require.NoError(t, err)
rt.events <- dagentruntime.ElicitationRequest("authorize", "url", nil, "https://example.com", "id", nil, "agent")
rt.events <- dagentruntime.ElicitationRequest("authorize", "url", nil, "https://example.com", "id", "", "sess", nil, "agent")
rt.events <- dagentruntime.MaxIterationsReached(3)
close(rt.events)

Expand Down
3 changes: 2 additions & 1 deletion pkg/leantui/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func (r *cycleThinkingRuntime) Run(context.Context, *session.Session) ([]session
return nil, nil
}
func (r *cycleThinkingRuntime) Resume(context.Context, runtime.ResumeRequest) {}
func (r *cycleThinkingRuntime) ResumeElicitation(context.Context, tools.ElicitationAction, map[string]any) error {
func (r *cycleThinkingRuntime) ResumeElicitation(context.Context, tools.ElicitationAction, map[string]any, ...string) error {
return nil
}
func (r *cycleThinkingRuntime) SessionStore() session.Store { return nil }
Expand Down Expand Up @@ -120,6 +120,7 @@ func (r *cycleThinkingRuntime) AvailableModels(context.Context) []runtime.ModelC
func (r *cycleThinkingRuntime) SupportsModelSwitching() bool { return r.supports }
func (r *cycleThinkingRuntime) OnToolsChanged(func(runtime.Event)) {}
func (r *cycleThinkingRuntime) OnBackgroundEvent(func(runtime.Event)) {}
func (r *cycleThinkingRuntime) OnElicitationRequest(func(runtime.Event)) {}

var _ runtime.Runtime = (*cycleThinkingRuntime)(nil)

Expand Down
35 changes: 30 additions & 5 deletions pkg/runtime/agent_delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,15 @@ func (r *LocalRuntime) runCollecting(ctx context.Context, parent *session.Sessio
if usage, ok := event.(*TokenUsageEvent); ok {
r.emitBackgroundEvent(usage)
}
// Elicitation requests are NOT re-forwarded here: elicitationHandler
// already delivered this event to the OnElicitationRequest sink
// directly, synchronously, and exactly once (#3584). Forwarding it
// again here — as the bridge's best-effort copy happens to flow
// through this same channel when this background sub-session
// currently owns the bridge slot — used to cause a second sink
// delivery for the same request, which only a stateful App-side
// dedupe (since removed) papered over. This branch is intentionally
// absent; ElicitationRequestEvents seen here are simply ignored.
if errEvt, ok := event.(*ErrorEvent); ok {
errMsg = errEvt.Error
break
Expand Down Expand Up @@ -416,15 +425,31 @@ func (r *LocalRuntime) runCollecting(ctx context.Context, parent *session.Sessio
// no explanation. Prepend an actionable note so the model (and, through it,
// the user) learns the server must be authorized interactively first.
if note := backgroundAuthRequiredNote(child); note != "" {
if result != "" {
result = note + "\n\n" + result
} else {
result = note
}
result = prependNote(result, note)
}
// Mid-call elicitations that were auto-declined because this background
// session had no UI to answer them (see elicitationHandler) are recorded
// against this sub-session's ID; surface them the same way (#3584).
for _, note := range r.elicitationDeclines.drain(s.ID) {
result = prependNote(result, note)
}
return &agenttool.RunResult{Result: result}
}

// prependNote prepends note to result, separated by a blank line, handling
// the case where either side is empty. Used to surface model-readable
// context (OAuth-required, elicitation auto-declined) ahead of a background
// sub-session's actual response.
func prependNote(result, note string) string {
if note == "" {
return result
}
if result == "" {
return note
}
return note + "\n\n" + result
}

// backgroundAuthRequiredNote returns a model-readable note naming the child
// agent's MCP toolsets that could not start because they require first-time
// interactive OAuth authorization, which a background agent cannot complete
Expand Down
Loading
Loading