bce9fd266a
* feat(backend): multi-question support for ask_user_question_kandev
Reshape the MCP tool from a single prompt+options pair to a questions[]
array (1-4 entries, single-choice each, custom text fallback). The
clarification HTTP handler gates the response until every question has an
answer, fans out one chat message per question (sharing pending_id, with
question_index/question_total in metadata), and returns a JSON map keyed
by question id back to the agent. The orchestrator resume prompt now
formats multi-line Q1/A1, Q2/A2 summaries when the bundle has more than
one question. Mock-agent gets a clarification-multi scenario and
ask_user_question_test.go covers the new schema + map response. No
back-compat shim — local-first tool, single-PR breaking change.
* feat(web): refresh clarification overlay with multi-question + modern UI
Each per-question chat message gets its own card with a "Question N of M"
progress chip, a ✓ Answered badge once committed, and shadcn-aligned
option buttons with kbd hints. A new useClarificationGroup hook coordinates
the per-question commits (decision A) and posts every answer in a single
batch once the user finishes the bundle; X rejects the whole group. The
single-question case keeps the 1-9 / Esc keyboard shortcuts. Playwright
e2e adds 6 multi-question scenarios (rendering, full happy path, partial
state, mixed option/custom, skip-rejects-bundle, sibling-state) on top of
the existing single-question regression suite.
* fix(web): clarification carousel UX with stepper navigation + race fix
Replaces the vertically stacked card layout with a horizontal stepper:
numbered circle dots up top show step state (active/answered/pending),
the single active question card sits in the middle, and Back / Next /
Submit buttons sit at the bottom. Picking an option auto-advances to the
next step (single-question case auto-submits, no explicit Submit button).
The stepper steps are clickable so users can revise prior answers.
Keyboard shortcuts: 1-9 for option pick, ArrowLeft/Right for navigation,
Esc for skip.
Also fixes a race that surfaced as a 400 BadRequest on the
/api/v1/clarification/:id/respond endpoint when the user clicked an
option before all N messages of a multi-question bundle had streamed in:
findPendingClarificationGroup now gates on metadata.question_total and
returns an empty array until the full bundle has arrived.
Playwright suite expanded from 6 to 12 multi-question scenarios:
stepper rendering, auto-advance, Back, stepper jump, Submit gating,
happy path, mixed custom-text, revise-via-jump, skip-from-mid-step,
number-key shortcuts, Esc skip, first-step Back disabled.
* fix(clarification): address CodeRabbit/Greptile/Claude review comments
Backend:
- Single source of truth for question validation: rename/export
NormalizeAndValidateQuestions and call it from both the HTTP and the
WebSocket-side MCP handlers, so duplicate question ids and bad option
counts can't slip through either path.
- httpRespond now validates that every answer references a real question
id from the bundle and that no id is answered twice (regression tests
added).
- questionsFromMessages reorders by metadata.question_index so the
reconstructed bundle preserves the original question order.
- adapters.CreateClarificationRequestMessages rolls back already-created
messages when a later write in the bundle fails.
- extractQuestionAnswers always returns a JSON map (even on rejection,
with an envelope { rejected, reject_reason }) and simplifyAnswer
preserves both selected_option and custom_text when both are present.
- MCP tool description: rewrite the response example to match the actual
output shape; add minItems / maxItems to the questions and options
arrays in the JSON Schema.
- Test helper: drop the local contains() shim, use strings.Contains.
Frontend:
- useMemo([props]) was a no-op (props is always a fresh ref); split into
[message, messages] so it actually memoizes.
- Drop the dead "expired" SubmitState branch — the backend never returns
410 from this endpoint.
- Remove the asymmetric option-list dimming: the agent now receives both
selected_option and custom_text together, so a half-grey state was
misleading.
- New tests: pending-clarification.test.ts covers
findPendingClarificationGroup including the question_total gate, and
use-clarification-group.test.ts covers the hook's record/submit/skip
flows and the override-merge path used by the single-question case.
* fix(web): treat clarification 409 as success so duplicate submit doesn't stick the overlay
* fix(clarification): unblock agent on persistence failure + nil-guard rejected branch
- Add Store.CancelRequest(pendingID) and call it from both the WebSocket
MCP handler and the HTTP create-request handler when message creation
fails. Without it, the agent's WaitForResponse parked for the full
2-hour MCP timeout while no clarification cards ever reached the user.
Both handlers now return an explicit error response on failure.
- applyAnswersToMessages now guards h.repo == nil in the rejected branch
(parity with sibling expectedQuestionIDs).
- extractQuestionAnswers stubs for unanswered questions in a rejected
bundle now carry { answered: false, rejected: true } so agents reading
per-question payloads see the rejection without inspecting the
envelope.
- Hook test: added 409-as-ok regression coverage for both submit and
skip paths so the previous fix can't drift.
- Store test: covers CancelRequest unblocking a parked WaitForResponse.
* fix: drop unused e2e helper + use channel sync in CancelRequest test
* refactor(web): drop dead 'message' prop from clarification overlay
Both call sites pass 'messages' (the bundle group), so the legacy
single-message prop was unused. Tightening the prop type also lets the
useMemo depend on a single value.
* fix(web): ignore modifier-keyed events in clarification carousel shortcuts
Bare 1..9 / ArrowLeft / ArrowRight / Esc still drive the carousel, but
Cmd/Ctrl+1..9 (browser tab switch) and Alt+ArrowLeft (browser back) now
pass through as the user expects.
111 lines
3.5 KiB
Go
111 lines
3.5 KiB
Go
// Package clarification provides types and services for agent clarification requests.
|
|
package clarification
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
taskmodels "github.com/kandev/kandev/internal/task/models"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Canceller wraps Store with message-update side effects.
|
|
// When the agent's turn completes, it cancels pending clarifications
|
|
// and marks the database messages with agent_disconnected metadata.
|
|
type Canceller struct {
|
|
store *Store
|
|
repo messageStore
|
|
eventBus EventBus
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewCanceller creates a Canceller.
|
|
func NewCanceller(store *Store, repo messageStore, eventBus EventBus, log *logger.Logger) *Canceller {
|
|
return &Canceller{
|
|
store: store,
|
|
repo: repo,
|
|
eventBus: eventBus,
|
|
logger: log.WithFields(zap.String("component", "clarification-canceller")),
|
|
}
|
|
}
|
|
|
|
// CancelSessionAndNotify cancels all pending clarifications for a session,
|
|
// unblocking WaitForResponse callers, and marks the database messages
|
|
// as expired (with agent_disconnected=true for context) so the frontend
|
|
// closes the interactive overlay and renders a "Timed out" history entry.
|
|
// Returns the number of cancelled clarifications.
|
|
//
|
|
// Setting status=expired also prevents a UX bug where clicking the overlay's
|
|
// X button after the agent moved on would trigger a new turn via the
|
|
// respond handler's event fallback path (rejected responses were being
|
|
// forwarded to the orchestrator as "User declined to answer").
|
|
func (c *Canceller) CancelSessionAndNotify(ctx context.Context, sessionID string) int {
|
|
pendingIDs := c.store.CancelSession(sessionID)
|
|
if len(pendingIDs) == 0 {
|
|
return 0
|
|
}
|
|
|
|
for _, id := range pendingIDs {
|
|
msgs, err := c.repo.FindMessagesByPendingID(ctx, id)
|
|
if err != nil || len(msgs) == 0 {
|
|
c.logger.Debug("messages not found for cancelled clarification",
|
|
zap.String("pending_id", id),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
for _, msg := range msgs {
|
|
if msg.Metadata == nil {
|
|
msg.Metadata = map[string]any{}
|
|
}
|
|
msg.Metadata["agent_disconnected"] = true
|
|
msg.Metadata["status"] = string(StatusExpired)
|
|
if err := c.repo.UpdateMessage(ctx, msg); err != nil {
|
|
c.logger.Warn("failed to update message with expired status",
|
|
zap.String("pending_id", id),
|
|
zap.String("message_id", msg.ID),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
c.publishMessageUpdated(ctx, msg)
|
|
}
|
|
}
|
|
|
|
return len(pendingIDs)
|
|
}
|
|
|
|
// publishMessageUpdated publishes a message.updated event to the event bus.
|
|
func (c *Canceller) publishMessageUpdated(ctx context.Context, msg *taskmodels.Message) {
|
|
if c.eventBus == nil {
|
|
return
|
|
}
|
|
|
|
msgType := string(msg.Type)
|
|
if msgType == "" {
|
|
msgType = "message"
|
|
}
|
|
|
|
data := map[string]any{
|
|
"message_id": msg.ID,
|
|
"session_id": msg.TaskSessionID,
|
|
"task_id": msg.TaskID,
|
|
"turn_id": msg.TurnID,
|
|
"author_type": string(msg.AuthorType),
|
|
"author_id": msg.AuthorID,
|
|
"content": msg.Content,
|
|
"type": msgType,
|
|
"requests_input": msg.RequestsInput,
|
|
"created_at": msg.CreatedAt.Format(time.RFC3339),
|
|
"metadata": msg.Metadata,
|
|
}
|
|
|
|
event := bus.NewEvent(events.MessageUpdated, "clarification-canceller", data)
|
|
if err := c.eventBus.Publish(ctx, events.MessageUpdated, event); err != nil {
|
|
c.logger.Warn("failed to publish message.updated event",
|
|
zap.String("message_id", msg.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|