Compare commits

...
Author SHA1 Message Date
Miguel Palhas b27dda6c6b Merge remote-tracking branch 'origin/main' into chorale
ci / web (push) Successful in 37s
ci / flake (push) Successful in 3m58s
ci / rust (push) Successful in 7m14s
2026-09-17 11:18:49 +01:00
Miguel PalhasandClaude Opus 4.6 5bdedc8d23 feat(maestro): add elicitation web UI and TUI stub
ElicitationView renders form-mode questions from the agent:
single-property string enums as a button group (the common
multiple-choice case), general schemas as a form with typed
inputs.

Wired into TranscriptRow, StructuredPane, and the model
helpers. TUI renders elicitation rows as plain text for now.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 08:32:11 +01:00
Miguel PalhasandClaude Opus 4.6 1e628789fb feat(maestro): add ACP elicitation support (backend)
Advertise `elicitation.form` in the ACP handshake and handle
`elicitation/create` requests end-to-end through the daemon.

Adds `Elicitation` transcript row kind, `pending_elicitation` to
ReducedState, the elicitation pump, persistence, and a new
`POST /api/sessions/:id/structured/elicitations/:nonce` endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-17 08:22:37 +01:00
Miguel Palhas 3f2473d910 feat(maestro): hide tab bar on mobile, add sidebar button to pane header
ci / web (push) Successful in 38s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m23s
On mobile only one session is focused at a time, so the tab bar is
redundant — the sidebar drawer is the canonical way to switch. A ☰
button in the pane header gives tap access without requiring a swipe.

Closes #273
2026-09-17 08:06:44 +01:00
Miguel PalhasandClaude Opus 4.6 b56a35f3ee fix(maestro): simplify tool-group test to match actual grouping
ci / web (push) Successful in 34s
ci / flake (push) Successful in 3m44s
ci / rust (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-15 11:23:28 +01:00
Miguel Palhas b1f6014a6a Merge remote-tracking branch 'origin/main' into tritone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 4m3s
ci / rust (push) Successful in 7m55s
2026-09-14 11:07:43 +01:00
Miguel PalhasandClaude Opus 4.6 01db6459cb fix(maestro): block event pump until turn is settled
The adapter returns the session/prompt response before the agent
finishes working.  Late-arriving notifications raced past the turn
marker and leaked into the next turn, making each response appear
one prompt behind.

The event pump now sends a settled oneshot through the done payload
and awaits it before processing further messages.
finish_queued_acp_turn fires it after persisting the turn marker and
dispatching any queued prompt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:22:59 +01:00
Miguel PalhasandClaude Opus 4.6 d332c43a9f fix(maestro): handle 202 as no-body response in jsonOrError
ci / web (push) Failing after 26s
ci / flake (push) Successful in 4m8s
ci / rust (push) Successful in 7m57s
The daemon returns 202 Accepted when a structured prompt is queued.
jsonOrError only skipped res.json() for 204, so the empty 202 body
caused a JSON parse error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-13 12:13:35 +01:00
Miguel Palhas 1cc05af323 feat(maestro): add just apk recipe for the Android debug build
ci / web (push) Failing after 32s
ci / flake (push) Successful in 4m30s
ci / rust (push) Successful in 8m7s
Runs ./gradlew assembleDebug and copies app-debug.apk next to the
android/ project root.
2026-09-13 02:43:07 +01:00
Miguel Palhas 6eb048575c feat(maestro): match adaptive icon to web PWA mark
Replaces the default Android Studio placeholder icon with the same
concentric-circle mark as web/public/icon.svg, scaled into the
existing 108x108 adaptive-icon vectors.
2026-09-13 02:43:04 +01:00
Miguel Palhas 5cdc6135f5 feat(maestro): match activity rail blink/flash to web timings
Replaces the tween/Reverse pulse with keyframe animations mirroring
web/src/components/sidebar/sidebar.css exactly: a hard 1200ms on/off
blink for AwaitingInput (steps(1), no crossfade) and a 1400ms
ease-out double-flash for Error.
2026-09-13 02:43:00 +01:00
Miguel Palhas f6be34ae48 feat(maestro): bundle JetBrains Mono and match web's type scale
Bundles JetBrainsMono Regular/Medium/Bold (SIL OFL, see
android/licenses/) so terminal and code content render the same face
as the web SPA's --font-mono instead of falling back to the device's
system mono. Typography now steps through the same sizes as the web
--fs-* tokens, using the platform sans for UI chrome.
2026-09-13 02:42:55 +01:00
Miguel Palhas 4f06d1fc8f feat(maestro): translate web OKLCH tokens to Material 3 color scheme
Maps bg/surface/ink/muted/faint/accent/live/danger/warn from
web/src/styles.css to background/surface/onSurface/onSurfaceVariant/
outline/primary/tertiary/error/secondary, for both dark (default) and
light Material 3 schemes. Drops the redundant statusBarColor override
in themes.xml now that MainActivity's enableEdgeToEdge() owns it.
2026-09-13 02:42:51 +01:00
35 changed files with 1287 additions and 95 deletions
+5
View File
@@ -23,6 +23,11 @@ web-typecheck:
pnpm -C web install --frozen-lockfile
pnpm -C web typecheck
# Build the Android debug APK and copy it next to the android/ project root.
apk:
cd android && ./gradlew assembleDebug
cp android/app/build/outputs/apk/debug/app-debug.apk android/app-debug.apk
# Full workspace build + tests.
test:
cargo fmt --all -- --check
+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle/
.kotlin/
/local.properties
/.idea/
.DS_Store
@@ -2,12 +2,12 @@ package pt.naps.maestro.ui.sidebar
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.InfiniteTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
@@ -52,18 +52,51 @@ fun activityStateWord(activity: ActivityState): String =
ActivityState.Error -> "Error"
}
/** A held bar that pulses its alpha between 1 and [minAlpha] every
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
/** A hard on/off blink, one cycle per 1200ms — mirrors the web rail's
* `maestro-rail-blink` keyframes (docs/SIDEBAR.md, web/src/components/sidebar/sidebar.css):
* held at full alpha for the first half of the cycle, then snapped to 0 for
* the second half, with no crossfade. */
@Composable
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
private fun InfiniteTransition.blinkAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = minAlpha,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = periodMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
animation = keyframes {
durationMillis = 1200
1f at 0
1f at 599
0f at 600
0f at 1199
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-alpha",
label = "activity-rail-blink",
)
return alpha
}
/** A fast double-flash every 1400ms — mirrors the web rail's
* `maestro-rail-flash` keyframes: two dips to quarter alpha early in the
* cycle, then held at full alpha for the remainder. */
@Composable
private fun InfiniteTransition.flashAlpha(): Float {
val alpha by animateFloat(
initialValue = 1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
1f at 0 using LinearOutSlowInEasing
0.25f at 210 using LinearOutSlowInEasing
1f at 420 using LinearOutSlowInEasing
0.25f at 630 using LinearOutSlowInEasing
1f at 840 using LinearOutSlowInEasing
1f at 1400
},
repeatMode = RepeatMode.Restart,
),
label = "activity-rail-flash",
)
return alpha
}
@@ -77,8 +110,8 @@ fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
val alpha = when (activity) {
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
ActivityState.AwaitingInput -> infinite.blinkAlpha()
ActivityState.Error -> infinite.flashAlpha()
else -> 1f
}
Box(
@@ -2,10 +2,45 @@ package pt.naps.maestro.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
/** Palette translated from the web SPA's OKLCH design tokens
* (web/src/styles.css) into sRGB. Dark is the default theme; Light is opt-in.
* Semantic mapping (see docs referenced from issue #271):
* bg -> background, surface* -> surface layers, ink -> onBackground/onSurface,
* muted -> onSurfaceVariant, faint -> outline, accent -> primary,
* live -> tertiary, danger -> error, warn -> secondary. */
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// ---- Dark theme (default) ----
val DarkBg = Color(0xFF0E1012)
val DarkSurface = Color(0xFF16181B)
val DarkSurface2 = Color(0xFF1E2125)
val DarkSurface3 = Color(0xFF272B2F)
val DarkBorder = Color(0xFF2E3136)
val DarkBorderStrong = Color(0xFF42474D)
val DarkInk = Color(0xFFEBEDEF)
val DarkMuted = Color(0xFFA1A5AA)
val DarkFaint = Color(0xFF777B7F)
val DarkAccent = Color(0xFFE2E5E8)
val DarkAccentStrong = Color(0xFFF4F5F7)
val DarkAccentInk = Color(0xFF13161C)
val DarkLive = Color(0xFF65EA92)
val DarkDanger = Color(0xFFFF6F69)
val DarkWarn = Color(0xFFEEBC4A)
val DarkTermBg = Color(0xFF0B0C0F)
// ---- Light theme ----
val LightBg = Color(0xFFF9FAFB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurface2 = Color(0xFFF2F3F5)
val LightSurface3 = Color(0xFFE6E8EA)
val LightBorder = Color(0xFFD9DBDF)
val LightBorderStrong = Color(0xFFC1C4C9)
val LightInk = Color(0xFF20242A)
val LightMuted = Color(0xFF565B62)
val LightFaint = Color(0xFF81878D)
val LightAccent = Color(0xFF23272B)
val LightAccentStrong = Color(0xFF13161B)
val LightAccentInk = Color(0xFFF9FAFB)
val LightLive = Color(0xFF069A4A)
val LightDanger = Color(0xFFD01D21)
val LightWarn = Color(0xFFC78200)
val LightTermBg = Color(0xFF0B0F14)
@@ -10,18 +10,71 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/** Dark scheme (default), translated from the web SPA's dark token set
* (web/src/styles.css) — see Color.kt for the token -> role mapping. */
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
background = DarkBg,
onBackground = DarkInk,
surface = DarkSurface,
onSurface = DarkInk,
surfaceVariant = DarkSurface2,
onSurfaceVariant = DarkMuted,
outline = DarkFaint,
outlineVariant = DarkBorder,
surfaceTint = DarkAccent,
primary = DarkAccent,
onPrimary = DarkAccentInk,
primaryContainer = DarkSurface3,
onPrimaryContainer = DarkInk,
secondary = DarkWarn,
onSecondary = DarkAccentInk,
secondaryContainer = DarkSurface3,
onSecondaryContainer = DarkInk,
tertiary = DarkLive,
onTertiary = DarkAccentInk,
tertiaryContainer = DarkSurface3,
onTertiaryContainer = DarkInk,
error = DarkDanger,
onError = DarkAccentInk,
errorContainer = DarkSurface3,
onErrorContainer = DarkDanger,
)
/** Light scheme, opt-in. */
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
background = LightBg,
onBackground = LightInk,
surface = LightSurface,
onSurface = LightInk,
surfaceVariant = LightSurface2,
onSurfaceVariant = LightMuted,
outline = LightFaint,
outlineVariant = LightBorder,
surfaceTint = LightAccent,
primary = LightAccent,
onPrimary = LightAccentInk,
primaryContainer = LightSurface3,
onPrimaryContainer = LightInk,
secondary = LightWarn,
onSecondary = LightAccentInk,
secondaryContainer = LightSurface3,
onSecondaryContainer = LightInk,
tertiary = LightLive,
onTertiary = LightAccentInk,
tertiaryContainer = LightSurface3,
onTertiaryContainer = LightInk,
error = LightDanger,
onError = LightAccentInk,
errorContainer = LightSurface3,
onErrorContainer = LightDanger,
)
/** Dark is the product default (docs/DESIGN.md); light is opt-in, and dynamic
* color (Material You, API 31+) is opt-in on top of that. Edge-to-edge itself
* is enabled once in MainActivity#onCreate — [enableEdgeToEdge] draws content
* behind transparent system bars for whichever scheme resolves here, and the
* default `SystemBarStyle.auto` picks light/dark bar icons from this theme's
* [darkTheme] flag, not the system setting. */
@Composable
fun MaestroTheme(
darkTheme: Boolean = true,
@@ -1,17 +1,99 @@
package pt.naps.maestro.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import pt.naps.maestro.R
/** Bundled (SIL OFL, android/licenses/JetBrainsMono-OFL.txt) rather than the
* system mono fallback, so the terminal pane and code blocks render the same
* face as the web SPA (--font-mono) on every device. */
val JetBrainsMono = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
)
/** UI chrome uses the platform sans (--font-sans is a system-ui stack on
* web); only terminal/code content switches to [JetBrainsMono]. */
private val Sans = FontFamily.Default
/** Scale roughly mirrors the web token steps (web/src/styles.css --fs-11
* through --fs-20); role names are Material3's, not the CSS ones. */
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
titleLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
letterSpacing = 0.sp,
),
titleSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
lineHeight = 22.sp,
letterSpacing = 0.1.sp,
),
bodyMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodySmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelLarge = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
),
labelSmall = TextStyle(
fontFamily = Sans,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.2.sp,
),
)
/** For terminal output and code blocks — callers set this explicitly instead
* of a [Typography] role, since mono content isn't part of the M3 type scale. */
val MonoTextStyle = TextStyle(
fontFamily = JetBrainsMono,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
)
@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Matches web/public/icon.svg's background so the adaptive icon reads as
the same mark as the PWA icon (issue #272). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B1B1B"
android:fillColor="#1A1D24"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,10 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Foreground layer of the adaptive icon: the same concentric-circle mark
as web/public/icon.svg, scaled from its 512 viewBox into this 108 one
(issue #272). Stays inside the adaptive-icon safe zone (r=35.86 of 54
half-width, ~66%). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#D0BCFF"
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
android:fillColor="#5FE3A1"
android:pathData="M39.23,54 A14.77,14.77 0 1,0 68.77,54 A14.77,14.77 0 1,0 39.23,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.5"
android:strokeWidth="2.53"
android:pathData="M28.69,54 A25.31,25.31 0 1,0 79.31,54 A25.31,25.31 0 1,0 28.69,54 Z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5FE3A1"
android:strokeAlpha="0.25"
android:strokeWidth="2.11"
android:pathData="M18.14,54 A35.86,35.86 0 1,0 89.86,54 A35.86,35.86 0 1,0 18.14,54 Z" />
</vector>
+6 -3
View File
@@ -1,6 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
<!-- No statusBarColor override: MainActivity#onCreate calls
enableEdgeToEdge(), which makes both system bars transparent at
runtime (issue #271). Setting one here would only show briefly
before that call lands. -->
<item name="android:windowBackground">#FF0E1012</item>
</style>
</resources>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+82 -10
View File
@@ -42,12 +42,13 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock, CurrentModeUpdate,
FileSystemCapabilities, Implementation, InitializeRequest, PromptRequest,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionId, SessionModeState, SessionNotification, SessionUpdate,
SetSessionConfigOptionRequest, SetSessionModeRequest,
CancelNotification, ClientCapabilities, ConfigOptionUpdate, ContentBlock,
CreateElicitationRequest, CreateElicitationResponse, CurrentModeUpdate, ElicitationAction,
ElicitationCapabilities, ElicitationFormCapabilities, FileSystemCapabilities, Implementation,
InitializeRequest, PromptRequest, RequestPermissionOutcome, RequestPermissionRequest,
RequestPermissionResponse, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
SessionConfigOptionValue, SessionConfigSelectOption, SessionId, SessionModeState,
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client, JsonRpcRequest, JsonRpcResponse};
@@ -69,13 +70,28 @@ pub enum AcpUpdate {
/// turn precede this marker on the wire, and therefore in this channel.
/// The event pump must fire `done` after persisting every preceding
/// notification.
///
/// The `done` oneshot carries both the prompt result and a `settled_tx`
/// that the receiver fires after the turn marker is persisted. The
/// event pump awaits the corresponding `settled_rx` before processing
/// further messages so late-arriving notifications cannot leak into the
/// next turn.
TurnDone {
turn: u64,
result: Result<(), String>,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
}
/// Payload sent through the `done` oneshot inside [`AcpUpdate::TurnDone`].
#[derive(Debug)]
pub struct TurnDonePayload {
pub result: Result<(), String>,
/// The receiver fires this after persisting the turn marker and
/// dispatching any queued prompt.
pub settled: oneshot::Sender<()>,
}
/// ACP adapter selected from the structured session's resolved command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpAdapter {
@@ -249,6 +265,15 @@ pub struct PermissionRequest {
pub response: oneshot::Sender<RequestPermissionOutcome>,
}
/// A blocked ACP elicitation request delivered to the daemon.
#[derive(Debug)]
pub struct ElicitationRequest {
/// The prompt turn that was active when ACP delivered this callback.
pub turn: u64,
pub request: CreateElicitationRequest,
pub response: oneshot::Sender<ElicitationAction>,
}
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
@@ -301,7 +326,7 @@ enum Command {
Prompt {
text: String,
turn: u64,
done: oneshot::Sender<Result<(), String>>,
done: oneshot::Sender<TurnDonePayload>,
},
SetConfig {
config_id: String,
@@ -374,6 +399,7 @@ pub struct AcpSession {
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<AcpUpdate>>>,
permission_requests: Mutex<Option<mpsc::UnboundedReceiver<PermissionRequest>>>,
elicitation_requests: Mutex<Option<mpsc::UnboundedReceiver<ElicitationRequest>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
@@ -424,6 +450,13 @@ impl AcpSession {
.take()
}
pub fn take_elicitation_requests(&self) -> Option<mpsc::UnboundedReceiver<ElicitationRequest>> {
self.elicitation_requests
.lock()
.expect("elicitation requests")
.take()
}
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
/// child is already gone, which is the outcome cancel was after.
pub fn cancel(&self) {
@@ -435,7 +468,7 @@ impl AcpSession {
&self,
text: String,
turn: u64,
) -> Result<oneshot::Receiver<Result<(), String>>, String> {
) -> Result<oneshot::Receiver<TurnDonePayload>, String> {
let (done_tx, done_rx) = oneshot::channel();
self.commands
.send(Command::Prompt {
@@ -558,6 +591,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (permission_tx, permission_rx) = mpsc::unbounded_channel();
let (elicitation_tx, elicitation_rx) = mpsc::unbounded_channel();
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
let (ready_tx, ready_rx) = oneshot::channel();
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
@@ -570,6 +604,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
},
updates_tx,
permission_tx,
elicitation_tx,
commands_rx,
ready_tx,
));
@@ -584,6 +619,7 @@ pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
child_commands: child_commands_tx,
updates: Mutex::new(Some(updates_rx)),
permission_requests: Mutex::new(Some(permission_rx)),
elicitation_requests: Mutex::new(Some(elicitation_rx)),
exit: exit_rx,
});
}
@@ -690,6 +726,7 @@ fn client_capabilities() -> ClientCapabilities {
.read_text_file(false)
.write_text_file(false))
.terminal(false)
.elicitation(ElicitationCapabilities::new().form(ElicitationFormCapabilities::default()))
}
fn advertised_config_options(
@@ -792,6 +829,7 @@ async fn run_connection<T>(
config: ConnectionConfig,
updates_tx: mpsc::UnboundedSender<AcpUpdate>,
permission_requests: mpsc::UnboundedSender<PermissionRequest>,
elicitation_requests: mpsc::UnboundedSender<ElicitationRequest>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
) where
@@ -807,6 +845,7 @@ async fn run_connection<T>(
// until a cancelled prompt settles, so this active turn is unambiguous.
let permission_turn = Arc::new(Mutex::new(PermissionTurnState::default()));
let permission_turn_seen = permission_turn.clone();
let elicitation_turn_seen = permission_turn.clone();
let received_updates = updates_tx.clone();
let result = Client
.builder()
@@ -852,6 +891,35 @@ async fn run_connection<T>(
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
async move |request: CreateElicitationRequest, responder, _cx| {
let turn = elicitation_turn_seen
.lock()
.expect("elicitation turn state")
.callback_turn();
let Some(turn) = turn else {
return responder.respond(CreateElicitationResponse::new(
ElicitationAction::Cancel,
));
};
let (response, received) = oneshot::channel();
if elicitation_requests
.send(ElicitationRequest {
turn,
request,
response,
})
.is_err()
{
return responder.respond(CreateElicitationResponse::new(
ElicitationAction::Cancel,
));
}
let action = received.await.unwrap_or(ElicitationAction::Cancel);
responder.respond(CreateElicitationResponse::new(action))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
let init = InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities())
@@ -999,7 +1067,11 @@ async fn run_connection<T>(
.expect("permission turn state")
.finish(turn);
if let Some(done) = done.lock().expect("prompt sender").take() {
let _ = done.send(Err(error.to_string()));
let (settled_tx, _) = oneshot::channel();
let _ = done.send(TurnDonePayload {
result: Err(error.to_string()),
settled: settled_tx,
});
}
}
}
+19 -5
View File
@@ -70,11 +70,12 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
pub fn decide_structured(
turn_active: bool,
pending_approval: bool,
pending_elicitation: bool,
turn_failed: bool,
) -> ActivityState {
if turn_failed {
ActivityState::Error
} else if pending_approval {
} else if pending_approval || pending_elicitation {
ActivityState::AwaitingInput
} else if turn_active {
ActivityState::Active
@@ -155,13 +156,26 @@ mod tests {
#[test]
fn structured_activity_follows_lifecycle_with_approval_precedence() {
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
assert_eq!(
decide_structured(true, true, false),
decide_structured(false, false, false, false),
ActivityState::Idle
);
assert_eq!(
decide_structured(true, false, false, false),
ActivityState::Active
);
assert_eq!(
decide_structured(true, true, false, false),
ActivityState::AwaitingInput
);
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
assert_eq!(
decide_structured(true, false, true, false),
ActivityState::AwaitingInput
);
assert_eq!(
decide_structured(false, false, false, true),
ActivityState::Error
);
}
#[test]
+53 -2
View File
@@ -22,8 +22,8 @@ use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
PROTOCOL_VERSION,
StructuredApprovalDecision, StructuredConfigRequest, StructuredElicitationAction,
StructuredEvent, StructuredPromptRequest, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -80,6 +80,10 @@ pub fn router(state: LocalApiState) -> Router {
.route(
routes::patterns::STRUCTURED_APPROVAL,
post(structured_approval),
)
.route(
routes::patterns::STRUCTURED_ELICITATION,
post(structured_elicitation),
);
#[cfg(feature = "haiku-autoname")]
{
@@ -577,6 +581,53 @@ async fn structured_approval(
}
}
async fn structured_elicitation(
State(s): State<LocalApiState>,
Path((id, nonce)): Path<(Uuid, String)>,
Json(body): Json<StructuredElicitationAction>,
) -> Response {
use maestro_acp::schema::{
ElicitationAcceptAction, ElicitationAction, ElicitationContentValue,
};
let action = match body {
StructuredElicitationAction::Accept { content } => {
let mapped: std::collections::BTreeMap<String, ElicitationContentValue> = content
.into_iter()
.filter_map(|(key, value)| {
let v = match value {
serde_json::Value::String(s) => ElicitationContentValue::String(s),
serde_json::Value::Bool(b) => ElicitationContentValue::Boolean(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
ElicitationContentValue::Integer(i)
} else {
ElicitationContentValue::Number(n.as_f64()?)
}
}
_ => return None,
};
Some((key, v))
})
.collect();
ElicitationAction::Accept(ElicitationAcceptAction::new().content(mapped))
}
StructuredElicitationAction::Decline => ElicitationAction::Decline,
StructuredElicitationAction::Cancel => ElicitationAction::Cancel,
};
match s.manager.resolve_acp_elicitation(id, &nonce, action).await {
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
Ok(None) => error_response(
StatusCode::CONFLICT,
"not_structured",
"session is not a live structured session",
),
Err(e) => {
let info = e.to_error_info();
error_response(session_error_status(&info.code), &info.code, &info.message)
}
}
}
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
/// scanner starts with its cache cold; wire
+40
View File
@@ -125,6 +125,7 @@ impl Projection {
crate::activity::decide_structured(
self.active_turn.is_some(),
self.state.pending_approval.is_some(),
self.state.pending_elicitation.is_some(),
self.turn_failed,
)
}
@@ -274,6 +275,45 @@ impl Projection {
})
}
/// Add a daemon-originated elicitation row. The nonce belongs only in
/// the reduced state, not in the row data.
pub fn request_elicitation(&mut self, nonce: String, data: Value) -> ProjectionUpdate {
let row = self.append(
format!("elicitation:{}", uuid::Uuid::new_v4()),
TranscriptRowKind::Elicitation,
data.clone(),
);
self.state.pending_elicitation = Some(serde_json::json!({
"nonce": nonce,
"request": data,
}));
ProjectionUpdate {
transcript: Some(row),
state: self.state(),
}
}
/// Clear an elicitation only when it is still the one the client answered.
pub fn resolve_elicitation(&mut self, nonce: &str) -> Option<ProjectionUpdate> {
let pending = self.state.pending_elicitation.as_ref()?;
if pending.get("nonce").and_then(Value::as_str) != Some(nonce) {
return None;
}
self.state.pending_elicitation = None;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
pub fn clear_elicitation(&mut self) -> Option<ProjectionUpdate> {
self.state.pending_elicitation.take()?;
Some(ProjectionUpdate {
transcript: None,
state: self.state(),
})
}
/// Undo an `append`ed row that a caller decides never happened — used
/// when persisting the row's own event fails (#184), so a snapshot taken
/// after the failure cannot show a row durable replay would never
+193 -8
View File
@@ -156,6 +156,7 @@ struct AcpEventStream {
projection: Projection,
projected_live: broadcast::Sender<ProjectionUpdate>,
pending_approvals: HashMap<String, PendingApproval>,
pending_elicitations: HashMap<String, PendingElicitation>,
/// Serializes prompt dispatch with cancellation. A prompt is not merely
/// claimed: it is queued onto ACP before another operation can proceed.
operation: Arc<Mutex<()>>,
@@ -205,6 +206,11 @@ struct PendingApproval {
options: Vec<maestro_acp::schema::PermissionOption>,
}
#[derive(Debug)]
struct PendingElicitation {
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
}
/// The outcome accepted by the structured approval endpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
@@ -712,6 +718,7 @@ impl SessionManager {
return Ok(false);
}
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
return Ok(false);
};
@@ -869,11 +876,24 @@ impl SessionManager {
stream.dispatch.queued_turn = Some(turn);
let manager = self.clone();
tokio::spawn(async move {
let lifecycle = match done.await {
Ok(Ok(())) => TurnLifecycle::Finish,
Ok(Err(_)) | Err(_) => TurnLifecycle::Failure,
let (lifecycle, settled) = match done.await {
Ok(payload) => {
let lifecycle = if payload.result.is_ok() {
TurnLifecycle::Finish
} else {
TurnLifecycle::Failure
};
(lifecycle, Some(payload.settled))
}
Err(_) => (TurnLifecycle::Failure, None),
};
manager.finish_queued_acp_turn(id, turn, lifecycle).await;
// Tell the event pump it may resume processing
// notifications — the turn marker is persisted
// and any queued prompt has been dispatched.
if let Some(settled) = settled {
let _ = settled.send(());
}
});
drop(stream);
sync_structured_activity(&self.inner, id).await;
@@ -885,6 +905,7 @@ impl SessionManager {
}
Err(message) => {
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let projected = stream
.projection
.turn_lifecycle(TurnLifecycle::Failure, turn)
@@ -955,6 +976,7 @@ impl SessionManager {
stream.dispatch.settled.notify_waiters();
if stream.projection.active_turn() == Some(turn) {
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
let projected = persist_turn_update(&mut stream, projected);
stream.broadcast(projected);
@@ -1077,6 +1099,7 @@ impl SessionManager {
let mut stream = event_stream.lock().await;
acp.cancel();
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
let cancelled_queued_turn = stream.dispatch.queued_turn;
if let Some(turn) = cancelled_queued_turn {
stream.dispatch.cancelled_turn = Some(turn);
@@ -1149,6 +1172,38 @@ impl SessionManager {
Ok(Some(()))
}
/// Resolve a daemon-issued elicitation nonce. Same idempotency rules as
/// `resolve_acp_approval`: stale/duplicate nonces succeed silently.
pub async fn resolve_acp_elicitation(
&self,
id: Uuid,
nonce: &str,
action: maestro_acp::schema::ElicitationAction,
) -> Result<Option<()>, SessionError> {
let stream = {
let sessions = self.inner.sessions.lock().await;
let record = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
if !record.is_live_structured() {
return Ok(None);
}
record.acp_events.clone()
};
let Some(stream) = stream else {
return Ok(None);
};
let mut stream = stream.lock().await;
let Some(pending) = stream.pending_elicitations.remove(nonce) else {
return Ok(Some(()));
};
let _ = pending.response.send(action);
if let Some(update) = stream.projection.resolve_elicitation(nonce) {
stream.broadcast(update);
}
drop(stream);
sync_structured_activity(&self.inner, id).await;
Ok(Some(()))
}
async fn cancel_acp_approvals(&self, id: Uuid) {
let stream = self
.inner
@@ -1158,7 +1213,9 @@ impl SessionManager {
.get(&id)
.and_then(|record| record.acp_events.clone());
if let Some(stream) = stream {
cancel_pending_approvals(&mut *stream.lock().await);
let mut events = stream.lock().await;
cancel_pending_approvals(&mut events);
cancel_pending_elicitations(&mut events);
}
}
@@ -1448,6 +1505,7 @@ impl SessionManager {
projection: Projection::default(),
projected_live: projected_tx,
pending_approvals: HashMap::new(),
pending_elicitations: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: VecDeque::new(),
@@ -1601,6 +1659,7 @@ impl SessionManager {
let acp = Arc::new(acp_session);
let done = start_acp_event_pump(&inner, id, &acp, events.clone());
start_acp_permission_pump(&inner, id, &acp, events.clone(), auto_approve);
start_acp_elicitation_pump(&inner, id, &acp, events.clone());
// Drain any prompts queued while the adapter was Starting.
let queued_text = {
let mut sessions = inner.sessions.lock().await;
@@ -1968,6 +2027,7 @@ impl SessionManager {
projection,
projected_live: projected_tx,
pending_approvals: HashMap::new(),
pending_elicitations: HashMap::new(),
operation: Arc::new(Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: session.queued_prompts.drain(..).collect(),
@@ -2026,7 +2086,14 @@ impl SessionManager {
);
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
start_acp_permission_pump(
&self.inner,
id,
&acp,
events.clone(),
session.auto_approve,
);
start_acp_elicitation_pump(&self.inner, id, &acp, events);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
@@ -2443,8 +2510,17 @@ fn start_acp_event_pump(
} => {
// Every notification for this turn has been persisted and
// broadcast above. Signal the done oneshot so
// finish_queued_acp_turn runs with the transcript complete.
let _ = done.send(result);
// finish_queued_acp_turn runs with the transcript complete,
// then wait for it to persist the turn marker before we
// resume — otherwise late-arriving notifications race into
// the next turn.
let (settled_tx, settled_rx) = tokio::sync::oneshot::channel();
let _ = done.send(maestro_acp::TurnDonePayload {
result,
settled: settled_tx,
});
// Block the pump until the turn is fully settled.
let _ = settled_rx.await;
}
}
}
@@ -2458,6 +2534,7 @@ fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> Pr
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
PersistedEvent::User { row }
| PersistedEvent::Approval { row }
| PersistedEvent::Elicitation { row }
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
}
}
@@ -2735,6 +2812,110 @@ fn permission_outcome(
)
}
// ---------------------------------------------------------------------------
// Elicitation pump
// ---------------------------------------------------------------------------
fn start_acp_elicitation_pump(
inner: &Arc<Inner>,
id: Uuid,
acp: &Arc<AcpSession>,
events: Arc<Mutex<AcpEventStream>>,
) {
let Some(mut requests) = acp.take_elicitation_requests() else {
return;
};
let weak_inner = Arc::downgrade(inner);
let weak_acp = Arc::downgrade(acp);
tokio::spawn(async move {
while let Some(maestro_acp::ElicitationRequest {
turn,
request,
response,
}) = requests.recv().await
{
let mut events = events.lock().await;
if events.projection.active_turn() != Some(turn) {
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
continue;
}
let outcome = record_elicitation_request(&mut events, request, response);
let fatal = matches!(outcome, RecordOutcome::Fatal);
if let RecordOutcome::Live(update) = outcome {
events.broadcast(*update);
}
drop(events);
if fatal {
tracing::error!(%id, "elicitation history append rollback did not restore consistency");
if let Some(acp) = weak_acp.upgrade() {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
}
if let Some(inner) = weak_inner.upgrade() {
mark_session_dead(&inner, id).await;
}
break;
}
if let Some(inner) = weak_inner.upgrade() {
sync_structured_activity(&inner, id).await;
}
}
});
}
fn record_elicitation_request(
events: &mut AcpEventStream,
request: maestro_acp::schema::CreateElicitationRequest,
response: oneshot::Sender<maestro_acp::schema::ElicitationAction>,
) -> RecordOutcome {
let nonce = Uuid::new_v4().to_string();
let data = match serde_json::to_value(&request) {
Ok(value) => value,
Err(_) => {
let _ = response.send(maestro_acp::schema::ElicitationAction::Cancel);
return RecordOutcome::Dropped;
}
};
cancel_pending_elicitations(events);
events
.pending_elicitations
.insert(nonce.clone(), PendingElicitation { response });
let update = events.projection.request_elicitation(nonce, data);
let Some(TranscriptDelta::Append { row }) = &update.transcript else {
return RecordOutcome::Live(Box::new(update));
};
let event = PersistedEvent::Elicitation { row: row.clone() };
match events.log.append(&event) {
Ok(seq) => {
let _ = events.live.send(LoggedEvent { seq, event });
RecordOutcome::Live(Box::new(update))
}
Err(AppendError::Failed(error)) => {
tracing::error!(%error, "elicitation history append failed");
events.projection.rollback_row(&row.id);
cancel_pending_elicitations(events);
RecordOutcome::Dropped
}
Err(fatal @ AppendError::Fatal { .. }) => {
tracing::error!(error = %fatal, "elicitation history append failed unrecoverably");
events.projection.rollback_row(&row.id);
cancel_pending_elicitations(events);
RecordOutcome::Fatal
}
}
}
fn cancel_pending_elicitations(events: &mut AcpEventStream) {
for (_, pending) in events.pending_elicitations.drain() {
let _ = pending
.response
.send(maestro_acp::schema::ElicitationAction::Cancel);
}
if let Some(update) = events.projection.clear_elicitation() {
events.broadcast(update);
}
}
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
if !*done.borrow() {
let _ = done.wait_for(|finished| *finished).await;
@@ -3110,6 +3291,7 @@ async fn mark_session_dead_with_worktree_settlement(
if let Some(stream) = stream {
let mut stream = stream.lock().await;
cancel_pending_approvals(&mut stream);
cancel_pending_elicitations(&mut stream);
stream.prompt_queue.clear();
}
tracing::info!(%id, "session process gone — marking dead");
@@ -3211,7 +3393,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
}
if let Some(acp) = &record.acp {
if let Some(stream) = &record.acp_events {
cancel_pending_approvals(&mut *stream.lock().await);
let mut events = stream.lock().await;
cancel_pending_approvals(&mut events);
cancel_pending_elicitations(&mut events);
}
// `session/cancel`, a short wait, then SIGTERM (#134).
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
@@ -3299,6 +3483,7 @@ mod tests {
projection: Projection::default(),
projected_live,
pending_approvals: std::collections::HashMap::new(),
pending_elicitations: std::collections::HashMap::new(),
operation: std::sync::Arc::new(tokio::sync::Mutex::new(())),
dispatch: AcpDispatchState::default(),
prompt_queue: std::collections::VecDeque::new(),
+3
View File
@@ -269,6 +269,9 @@ pub enum PersistedEvent {
Approval {
row: TranscriptRow,
},
Elicitation {
row: TranscriptRow,
},
Turn {
row: TranscriptRow,
},
+1
View File
@@ -105,6 +105,7 @@ fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
.and_then(serde_json::Value::as_str)
.unwrap_or("[turn]"),
maestro_daemon::sidecar::PersistedEvent::Approval { .. } => "[approval]",
maestro_daemon::sidecar::PersistedEvent::Elicitation { .. } => "[elicitation]",
}
}
+18
View File
@@ -806,6 +806,7 @@ pub enum TranscriptRowKind {
ToolCall,
ToolDiff,
Approval,
Elicitation,
Plan,
Notice,
}
@@ -871,6 +872,8 @@ pub struct ReducedState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_approval: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_elicitation: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
@@ -927,6 +930,21 @@ pub enum StructuredApprovalDecision {
Deny,
}
/// Operator response to a pending ACP elicitation request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum StructuredElicitationAction {
/// The operator filled in the form and submitted.
Accept {
#[serde(default)]
content: std::collections::BTreeMap<String, Value>,
},
/// The operator declined to answer.
Decline,
/// The elicitation was cancelled.
Cancel,
}
// ---------------------------------------------------------------------------
// Client ↔ Daemon control messages (text frames on the attach WS)
// ---------------------------------------------------------------------------
+6
View File
@@ -106,6 +106,11 @@ pub fn structured_approval(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/approvals/{nonce}")
}
/// `POST /api/sessions/:id/structured/elicitations/:nonce` — answer ACP elicitation.
pub fn structured_elicitation(id: Uuid, nonce: &str) -> String {
format!("{SESSIONS}/{id}/structured/elicitations/{nonce}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
@@ -122,6 +127,7 @@ pub mod patterns {
pub const STRUCTURED_CANCEL: &str = "/api/sessions/:id/structured/cancel";
pub const STRUCTURED_CONFIG: &str = "/api/sessions/:id/structured/config";
pub const STRUCTURED_APPROVAL: &str = "/api/sessions/:id/structured/approvals/:nonce";
pub const STRUCTURED_ELICITATION: &str = "/api/sessions/:id/structured/elicitations/:nonce";
}
/// A daemon base URL that could not be turned into a WebSocket base.
+10
View File
@@ -355,6 +355,15 @@ fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
Tone::Muted,
false,
),
TranscriptRowKind::Elicitation => push_wrapped(
&mut out,
row,
"? ",
content_text(row),
row_width,
Tone::Muted,
false,
),
TranscriptRowKind::Plan => {}
}
if out.len() > start
@@ -399,6 +408,7 @@ pub fn copy_text(row: &TranscriptRow) -> String {
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested")
.into(),
TranscriptRowKind::Elicitation => content_text(row),
TranscriptRowKind::Notice => "[notice]".into(),
TranscriptRowKind::Plan => String::new(),
}
+64 -23
View File
@@ -6,11 +6,11 @@ pane. Alongside `docs/WORKTREES.md`, `docs/SIDEBAR.md` and `docs/RESTART.md`,
read this before changing anything that renders a structured session — both
the web and TUI renderers are written against it.
It covers the eight transcript row kinds, what the daemon decides versus what
It covers the nine transcript row kinds, what the daemon decides versus what
a client decides, the two projections and their delta/cursor semantics, the
ACP handshake and the capabilities it declines, the adapter's install
command, and the approval nonce rules. **Non-goal: the detached runner.** It
does not exist yet, and nothing here describes it.
command, the approval nonce rules, and the elicitation flow. **Non-goal: the
detached runner.** It does not exist yet, and nothing here describes it.
## What a structured session is
@@ -88,10 +88,12 @@ declares, deliberately, almost nothing:
Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because neither supported
adapter needs it, not because it was weighed and refused.
add a hop with no new capability.
The daemon does declare `elicitation.form` support. This tells the adapter
that form-mode elicitation (`CreateElicitationRequest`) will be handled and
resolved by the operator, rather than declined outright. See
[Elicitations](#elicitations) below.
The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below.
@@ -111,12 +113,12 @@ in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
those fold into the same nine rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds
## The nine transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
transcript is written in. Every row has a stable `id` (so later deltas can
@@ -132,6 +134,7 @@ crate.
| `ToolCall` | A `tool_call` / `tool_call_update` pair whose content carries no diff. | `tool:<tool_call_id>` — one row for the call's whole life; updates patch it in place, they never create a second row. |
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Elicitation` | A daemon-originated elicitation request (`CreateElicitationRequest`), serialized without its nonce. Carries `message` and `requestedSchema` (a JSON Schema object with typed properties). | `elicitation:<uuid>` — minted fresh per request, same lifecycle as `Approval`. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
@@ -139,7 +142,7 @@ crate.
`agent-client-protocol` dependency's schema already deserializes (it
currently folds `available_commands_update` and `session_info_update`
there) but the projection's `match` doesn't fold into one of the other
seven kinds. It is not a wire-level forward-compatibility seam:
eight kinds. It is not a wire-level forward-compatibility seam:
`SessionUpdate` is an internally-tagged enum with no unknown-variant
fallback, so a `session/update` whose `sessionUpdate` tag this build's
dependency has never heard of — including a variant gated behind an
@@ -174,6 +177,8 @@ choices, not daemon ones.
- Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce.
- Elicitation nonce minting and the single-active-elicitation rule (see
[Elicitations](#elicitations)) — same lifecycle as approvals.
- What happens to an ACP update variant the dependency decodes but nobody has
taught the projection to special-case (falls back to `Notice` rather than
erroring). A variant the dependency itself can't decode is a deserialization
@@ -195,7 +200,8 @@ choices, not daemon ones.
text → group → text → group rather than lumping every tool in a turn into
one box. Text and thought rows stay outside groups as standalone transcript
items — the operator sees all reasoning without expanding. Resolved
approval rows between tools within a run are absorbed into the group. The
approval and elicitation rows between tools within a run are absorbed into
the group. The
collapsed header reads "N tool calls · Label 'arg'" where the label and
argument come from the last tool row's `splitToolTitle` — the operator
sees which command is current at a glance. Opening the group reveals tool
@@ -213,6 +219,9 @@ choices, not daemon ones.
- How the operator is asked to resolve an `Approval` row — the daemon only
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
never the UI that solicits it.
- How the operator is asked to resolve an `Elicitation` row — the daemon
exposes the three actions (`Accept` with content / `Decline` / `Cancel`);
the client decides the form layout and button treatment.
- Scrolling, collapsing, and every other presentation-only behaviour.
## The two projections
@@ -225,8 +234,8 @@ exposes two independent views, both defined in `maestro-protocol`:
It is intentionally always the *whole* transcript, not a windowed one: a
tool row patched much later must still find its original entry.
- **`ReducedState`** — one whole-session snapshot folded over every event
seen: `turn` (the per-turn indicators above), `pending_approval`, `plan`,
`usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
seen: `turn` (the per-turn indicators above), `pending_approval`,
`pending_elicitation`, `plan`, `usage`/`context`, `model`, and `mode`. Like `TranscriptSnapshot`, it is
always full-history, never a delta — a client that only wants "is a turn
active right now" never has to replay anything to answer that.
@@ -323,6 +332,37 @@ pending decision, entirely on the daemon side:
permanent one (`RejectAlways`) only when that is the sole rejection option
offered; if neither is present, it answers `Cancelled`.
## Elicitations
An ACP `elicitation/create` callback becomes an `Elicitation` row plus a
pending decision, mirroring the approval lifecycle:
- **Generation.** The daemon mints a nonce per request (`Uuid::new_v4()`),
same as approvals.
- **Live-only placement.** The nonce lives in `pending_elicitations` and in
`ReducedState.pending_elicitation.nonce`, never in the row's `data`.
- **One live elicitation at a time.** A new elicitation cancels any
prior pending one before installing itself.
- **Resolution.** `POST /api/sessions/:id/structured/elicitations/:nonce`
accepts `{ "action": "accept", "content": { ... } }`,
`{ "action": "decline" }`, or `{ "action": "cancel" }`. The endpoint
always answers `204` for a known, live structured session, same as
approvals. The three actions map to ACP's `ElicitationAction::Accept`
(with the filled-in values as `ElicitationContentValue` entries),
`Decline`, and `Cancel`.
- **Cancellation.** Turn completion, session cancel, and fatal errors
cancel pending elicitations as `Cancel`, same as approvals.
- **Schema.** The row's `data.requestedSchema` is a JSON Schema object
whose properties describe the fields the agent wants filled:
`StringPropertySchema` (with optional `enum`/`oneOf` for multiple-choice),
`BooleanPropertySchema`, `NumberPropertySchema`, `IntegerPropertySchema`.
Claude Code's common case is a single-property string enum — a
multiple-choice question with titled options.
- **Rendering.** Clients render the `message` as the question and the schema
properties as form fields or button groups. The web client detects the
single-property string enum fast path and renders titled buttons. Resolved
elicitations collapse to a compact one-liner.
## Prompt and cancel: bounded liveness
The web composer accepts up to ten files through its attachment picker, file
@@ -364,8 +404,8 @@ allowed to claim the daemon's idea of "the active turn."
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. The prompt queue
- Cancel sends `session/cancel`, clears any pending approval and elicitation
as `Cancelled`, and ends the turn's client-visible lifecycle immediately. The prompt queue
is preserved: after the cancelled turn settles, the queued prompts drain as
the next turn. If the session dies instead (settlement timeout), the queue
is cleared. If the cancelled turn was still *dispatched* to ACP (waiting on
@@ -390,8 +430,8 @@ directory: the sidecar itself (`<id>.json`), plus two next to it:
- **`<id>.events.jsonl`** — the unified event log. Each line is either a raw
ACP `SessionNotification` (kept in its original JSON so old logs stay
readable) or a `{"maestroEvent": "user"|"approval", "row": …}` envelope
for a daemon-generated row. Both share the same 1-based line-position cursor. A
readable) or a `{"maestroEvent": "user"|"approval"|"elicitation", "row": …}`
envelope for a daemon-generated row. Both share the same 1-based line-position cursor. A
line a hard kill left half-written is terminated before the next append, so
it keeps its position and is skipped as malformed rather than corrupting
what follows. An append that fails *while the daemon is running* — a
@@ -453,12 +493,13 @@ itself failing) leaves it `Dead` with the failure reason as
`activity_detail` — the same "tell the operator what to fix" pattern as a
missing adapter binary.
Pending approvals and their nonces never survive any of this. They live only
in the in-memory `pending_approvals` map and the live `ReducedState`, both
gone the instant the daemon that held them exits. Whatever ACP permission
request was mid-flight when the old daemon died was already answered
`Cancelled` on that daemon's own shutdown path, so a resumed session always
starts with no pending approval.
Pending approvals and elicitations and their nonces never survive any of
this. They live only in the in-memory `pending_approvals` /
`pending_elicitations` maps and the live `ReducedState`, both gone the
instant the daemon that held them exits. Whatever ACP permission or
elicitation request was mid-flight when the old daemon died was already
answered `Cancelled` on that daemon's own shutdown path, so a resumed
session always starts with no pending approval or elicitation.
See `docs/RESTART.md` for what a *client* observes across a daemon restart,
now that structured sessions exist alongside terminal ones.
+18 -1
View File
@@ -12,6 +12,7 @@ import type {
RecentDirs,
Session,
StructuredApprovalDecision,
StructuredElicitationAction,
StructuredConfigRequest,
StructuredPromptRequest,
Uuid,
@@ -39,7 +40,7 @@ export class ApiRequestError extends Error {
async function jsonOrError<T>(res: Response): Promise<T> {
if (res.ok) {
if (res.status === 204) return undefined as unknown as T;
if (res.status === 204 || res.status === 202) return undefined as unknown as T;
return (await res.json()) as T;
}
try {
@@ -219,4 +220,20 @@ export async function resolveStructuredApproval(
);
}
/** Resolve a pending elicitation. Same idempotency as approvals. */
export async function resolveStructuredElicitation(
id: Uuid,
nonce: string,
action: StructuredElicitationAction,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredElicitation(id, nonce), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(action),
}),
);
}
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
+9
View File
@@ -11,6 +11,7 @@ import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { requestToggleDrawer } from "../../uiEvents.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
@@ -204,6 +205,14 @@ function Pane({
<div
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
>
<button
type="button"
className="sessions-btn mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Sessions"
onClick={() => requestToggleDrawer()}
>
☰
</button>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
@@ -0,0 +1,229 @@
// An `Elicitation` row. The daemon mints the nonce; this only solicits one
// of the three possible actions (accept, decline, cancel).
import { useState } from "react";
import type {
AcpElicitationProperty,
AcpElicitationSchema,
StructuredElicitationAction,
} from "../../types.js";
export interface ElicitationViewProps {
/** The `CreateElicitationRequest` the row carries — serialized without
* its nonce, which never leaves the daemon's live wire. */
request: {
message?: string;
requestedSchema?: AcpElicitationSchema;
};
/** Whether this is the one live elicitation. */
pending: boolean;
onDecide: (action: StructuredElicitationAction) => Promise<void>;
}
/** For a single-property string enum schema (the common Claude Code
* multiple-choice case), extract the property key and its options. */
function singleSelectEnum(
schema: AcpElicitationSchema | undefined,
): { key: string; options: Array<{ value: string; title: string; description?: string }> } | null {
if (!schema) return null;
const keys = Object.keys(schema.properties);
if (keys.length !== 1) return null;
const key = keys[0];
const prop = schema.properties[key];
if (prop.oneOf && prop.oneOf.length > 0) {
return {
key,
options: prop.oneOf.map((o) => ({
value: o.const,
title: o.title,
description: o.description,
})),
};
}
if (prop.enum && prop.enum.length > 0) {
return {
key,
options: prop.enum.map((v) => ({ value: v, title: v })),
};
}
return null;
}
export function ElicitationView({ request, pending, onDecide }: ElicitationViewProps) {
const [busy, setBusy] = useState(false);
const [formValues, setFormValues] = useState<Record<string, string | number | boolean>>({});
const message = request.message ?? "Input requested";
const schema = request.requestedSchema;
if (!pending) {
return <div className="st-row st-row-elicitation st-elicitation-compact">{message}</div>;
}
const decide = (action: StructuredElicitationAction) => {
setBusy(true);
onDecide(action).catch(() => setBusy(false));
};
const singleSelect = singleSelectEnum(schema);
// Fast path: single-property string enum → button group (the common
// Claude Code multiple-choice question).
if (singleSelect) {
return (
<div className="st-row st-row-elicitation">
<div className="st-elicitation">
<div className="st-elicitation-title">
<span aria-hidden="true">?</span> {message}
</div>
<div className="st-elicitation-options">
{singleSelect.options.map((opt) => (
<button
key={opt.value}
type="button"
className="act"
disabled={busy}
onClick={() =>
decide({ action: "accept", content: { [singleSelect.key]: opt.value } })
}
>
{opt.title}
</button>
))}
</div>
<div className="st-elicitation-acts">
<button
type="button"
className="act deny"
disabled={busy}
onClick={() => decide({ action: "decline" })}
>
Skip
</button>
</div>
</div>
</div>
);
}
// General form: render inputs for each property.
const properties = schema?.properties ?? {};
const propertyKeys = Object.keys(properties);
const requiredSet = new Set(schema?.required ?? []);
const handleSubmit = () => {
decide({ action: "accept", content: formValues });
};
const updateField = (key: string, value: string | number | boolean) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
};
return (
<div className="st-row st-row-elicitation">
<div className="st-elicitation">
<div className="st-elicitation-title">
<span aria-hidden="true">?</span> {message}
</div>
<div className="st-elicitation-form">
{propertyKeys.map((key) => {
const prop = properties[key];
return (
<ElicitationField
key={key}
name={key}
prop={prop}
required={requiredSet.has(key)}
disabled={busy}
value={formValues[key]}
onChange={(v) => updateField(key, v)}
/>
);
})}
</div>
<div className="st-elicitation-acts">
<button type="button" className="act primary" disabled={busy} onClick={handleSubmit}>
Submit
</button>
<button
type="button"
className="act deny"
disabled={busy}
onClick={() => decide({ action: "decline" })}
>
Skip
</button>
</div>
</div>
</div>
);
}
function ElicitationField({
name,
prop,
required,
disabled,
value,
onChange,
}: {
name: string;
prop: AcpElicitationProperty;
required: boolean;
disabled: boolean;
value: string | number | boolean | undefined;
onChange: (value: string | number | boolean) => void;
}) {
const label = prop.title ?? name;
const type = prop.type ?? "string";
if (type === "boolean") {
return (
<label className="st-elicitation-field">
<input
type="checkbox"
checked={typeof value === "boolean" ? value : (prop.default === true)}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
</label>
);
}
if (type === "number" || type === "integer") {
return (
<label className="st-elicitation-field">
{label}
{required && <span className="st-elicitation-required">*</span>}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
<input
type="number"
step={type === "integer" ? 1 : "any"}
min={prop.minimum}
max={prop.maximum}
value={typeof value === "number" ? value : ""}
disabled={disabled}
onChange={(e) => onChange(type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value))}
/>
</label>
);
}
// Default: string
return (
<label className="st-elicitation-field">
{label}
{required && <span className="st-elicitation-required">*</span>}
{prop.description && <span className="st-elicitation-desc">{prop.description}</span>}
<input
type="text"
value={typeof value === "string" ? value : ""}
disabled={disabled}
placeholder={typeof prop.default === "string" ? prop.default : undefined}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
@@ -170,16 +170,14 @@ describe("tool rows and activity groups", () => {
snapshot(socket, [
chunk("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
chunk("agent:1", "agent_text", "still working"),
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
chunk("agent:2", "agent_text", "the answer"),
chunk("agent:1", "agent_text", "the answer"),
]);
const head = screen.getByText(/^2 tool calls/).closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
// The final answer stays outside the group.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
@@ -21,6 +21,7 @@ import {
import {
cancelStructuredTurn,
resolveStructuredApproval,
resolveStructuredElicitation,
sendStructuredPrompt,
setStructuredConfig,
uploadStructuredFile,
@@ -29,6 +30,7 @@ import { formatContextTokens, formatElapsed } from "./render.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
StructuredElicitationAction,
TranscriptRow as Row,
Uuid,
} from "../../types.js";
@@ -36,6 +38,7 @@ import { Composer } from "./Composer.js";
import {
commandsFromTranscript,
pendingApprovalRowId as pendingApprovalRowIdOf,
pendingElicitationRowId as pendingElicitationRowIdOf,
transcriptItems,
turnIsActive,
} from "./model.js";
@@ -124,9 +127,10 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
const turnActive = turnIsActive(state);
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
const pendingElicitationRowId = useMemo(() => pendingElicitationRowIdOf(rows, state), [rows, state]);
const items = useMemo(
() => transcriptItems(rows, pendingRowId, turnActive),
[rows, pendingRowId, turnActive],
() => transcriptItems(rows, pendingRowId, turnActive, pendingElicitationRowId),
[rows, pendingRowId, turnActive, pendingElicitationRowId],
);
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
@@ -193,6 +197,20 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
[sessionId, state.pending_approval?.nonce, pendingRowId],
);
const decideElicitation = useCallback(
async (rowId: string, action: StructuredElicitationAction): Promise<void> => {
const nonce = state.pending_elicitation?.nonce;
if (!nonce || rowId !== pendingElicitationRowId) return;
try {
await resolveStructuredElicitation(sessionId, nonce, action);
} catch (e: unknown) {
setError(String(e));
throw e;
}
},
[sessionId, state.pending_elicitation?.nonce, pendingElicitationRowId],
);
const dispatchPrompt = useCallback(
(text: string, files: File[]) => {
const generation = ++submission.current;
@@ -311,6 +329,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
row={row}
pendingApproval={row.id === pendingRowId}
onDecide={(decision) => decide(row.id, decision)}
pendingElicitation={row.id === pendingElicitationRowId}
onElicitationDecide={(action) => decideElicitation(row.id, action)}
expanded={expandedRows.has(row.id)}
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
imageUrls={imageUrls.current}
@@ -10,14 +10,17 @@ import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
import type {
AcpContentBlock,
AcpElicitationSchema,
AcpPermissionOption,
AcpToolCallContent,
AcpToolCallLike,
StructuredApprovalDecision,
StructuredElicitationAction,
TranscriptRow as Row,
TurnMeta,
} from "../../types.js";
import { ApprovalView } from "./ApprovalView.js";
import { ElicitationView } from "./ElicitationView.js";
import { Copyable } from "./Copyable.js";
import { DiffView } from "./DiffView.js";
import {
@@ -337,6 +340,9 @@ export interface TranscriptRowProps {
* rules). Every other approval row is history. */
pendingApproval?: boolean;
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
/** This row is the one live elicitation. */
pendingElicitation?: boolean;
onElicitationDecide?: (action: StructuredElicitationAction) => Promise<void>;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
imageUrls?: Map<string, string>;
@@ -348,6 +354,8 @@ export function TranscriptRow({
row,
pendingApproval = false,
onDecide,
pendingElicitation = false,
onElicitationDecide,
expanded = false,
onExpandedChange,
imageUrls,
@@ -374,6 +382,16 @@ export function TranscriptRow({
/>
);
}
case "elicitation": {
const request = (row.data ?? {}) as { message?: string; requestedSchema?: AcpElicitationSchema };
return (
<ElicitationView
request={request}
pending={pendingElicitation}
onDecide={onElicitationDecide ?? (() => Promise.resolve())}
/>
);
}
case "plan":
return null;
case "notice": {
+15 -5
View File
@@ -134,6 +134,11 @@ export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState)
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
}
/** Same for elicitations — one live at a time. */
export function pendingElicitationRowId(rows: TranscriptRow[], state: ReducedState): string | null {
return state.pending_elicitation ? lastRowIdOfKind(rows, "elicitation") : null;
}
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
return null;
@@ -168,6 +173,7 @@ export function activityGroupsByTurn(
rows: TranscriptRow[],
pendingApprovalId: string | null,
_turnActive: boolean,
pendingElicitationId: string | null = null,
): Map<string, ActivityGroup> {
const groups = new Map<string, ActivityGroup>();
let turnId = "before-first-message";
@@ -204,8 +210,10 @@ export function activityGroupsByTurn(
flushRun();
continue;
}
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isResolvedApproval) {
const isResolvedInteraction =
(row.kind === "approval" && row.id !== pendingApprovalId) ||
(row.kind === "elicitation" && row.id !== pendingElicitationId);
if (isResolvedInteraction) {
runPending.push(row);
continue;
}
@@ -239,8 +247,9 @@ export function transcriptItems(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
pendingElicitationId: string | null = null,
): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive, pendingElicitationId);
// Reverse index: row id → group id that contains it.
const rowToGroup = new Map<string, string>();
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
@@ -259,9 +268,10 @@ export function transcriptItems(
}
if (row.kind === "plan") continue;
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
// A resolved approval outside a group adds nothing the tool row does not
// already say. A pending one always renders.
// A resolved approval/elicitation outside a group adds nothing the tool
// row does not already say. A pending one always renders.
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
if (row.kind === "elicitation" && row.id !== pendingElicitationId) continue;
items.push({ type: "row", row });
}
@@ -913,6 +913,90 @@
font-style: italic;
}
/* Elicitation: the structured question the agent asks the operator.
Visually parallel to approvals but accent-tinted instead of warn-tinted. */
.st-elicitation {
border: 1px solid var(--accent);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--accent) 10%, var(--surface));
box-shadow: var(--shadow-1);
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.st-elicitation-title {
color: var(--accent);
font-size: var(--fs-11);
letter-spacing: 0.1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.st-elicitation-options {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.st-elicitation-acts {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.st-elicitation-form {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.st-elicitation-field {
display: flex;
flex-direction: column;
gap: var(--sp-1);
font-size: var(--fs-12);
color: var(--ink);
}
.st-elicitation-field input[type="text"],
.st-elicitation-field input[type="number"] {
height: 30px;
padding: 0 var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
background: var(--surface);
color: var(--ink);
font: inherit;
}
.st-elicitation-field input[type="checkbox"] {
margin-right: var(--sp-1);
accent-color: var(--accent);
}
.st-elicitation-required {
color: var(--danger);
}
.st-elicitation-desc {
color: var(--muted);
font-size: var(--fs-11);
}
.st-elicitation-compact {
font-size: var(--fs-11);
color: var(--faint);
}
.st-row-elicitation:hover {
background: var(--surface-2);
}
.act {
appearance: none;
font: inherit;
+6
View File
@@ -67,6 +67,12 @@ export function structuredApproval(id: Uuid, nonce: string): string {
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
}
/** `POST /api/sessions/:id/structured/elicitations/:nonce` — answer an
* elicitation request. */
export function structuredElicitation(id: Uuid, nonce: string): string {
return `${session(id)}/structured/elicitations/${encodeURIComponent(nonce)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
+6
View File
@@ -4651,6 +4651,12 @@ input.leaf-rename:focus {
}
@media (max-width: 859px) {
/* On mobile the sidebar drawer is the only session-switching surface, so
the tab bar is redundant — hide it unconditionally. */
.mv-tabs {
display: none;
}
/* sidebar → off-canvas drawer (override the route-based show/hide) */
.app-shell > .sidebar {
display: flex !important;
+31
View File
@@ -395,6 +395,7 @@ export type TranscriptRowKind =
| "tool_call"
| "tool_diff"
| "approval"
| "elicitation"
| "plan"
| "notice";
@@ -435,6 +436,7 @@ export interface TurnState {
export interface ReducedState {
turn: TurnState;
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
pending_elicitation?: { nonce: string; message?: string; schema?: AcpElicitationSchema } | null;
plan?: AcpPlan | null;
usage?: AcpUsage | null;
context?: AcpUsage | null;
@@ -486,6 +488,35 @@ export interface AcpSessionConfigOption {
* string (a bracketless Rust enum), not `{decision: ...}`. */
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
/** `POST /api/sessions/:id/structured/elicitations/:nonce` body. */
export type StructuredElicitationAction =
| { action: "accept"; content: Record<string, string | number | boolean> }
| { action: "decline" }
| { action: "cancel" };
/** Property schema within an ACP elicitation request. */
export interface AcpElicitationProperty {
type?: "string" | "number" | "integer" | "boolean";
title?: string;
description?: string;
default?: string | number | boolean;
enum?: string[];
oneOf?: Array<{ const: string; title: string; description?: string }>;
minLength?: number;
maxLength?: number;
minimum?: number;
maximum?: number;
}
/** The `requested_schema` carried by an ACP `elicitation/create` request. */
export interface AcpElicitationSchema {
type?: "object";
title?: string;
description?: string;
properties: Record<string, AcpElicitationProperty>;
required?: string[];
}
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
export interface AcpEmbeddedResource {