Skip to content

Commit 614c3f9

Browse files
jkyberneeesclaude
andauthored
feat: signal superseded drafts and verification outcome to WebSocket clients (#305)
* feat(serve): signal superseded drafts and verification outcome to clients When the loop re-asks the model after a final-looking answer (completion nudge or hint-mode verification retry), WebSocket clients received both answers as ordinary text, and a strict-mode verification failure never reached the live stream. - loop: synchronous AnswerEventHandler (draft superseded, verification started/completed) so frames stay in order with token_delta and land before done; Engine/Agent.VerifyOutcome; superseded drafts persist superseded + superseded_reason. - serve: answer_superseded frame (turn-tagged, only for drafts streamed live), verification runtime_event frames (no verifier prose), done.verified; the bulk send skips drafts. - REST runs reset the accumulated result on answer_superseded, the memory buffer keeps the turn's final answer, and markdown export labels drafts. - docs: protocol entries and a "Superseded drafts and verification" section in docs/WEBUI.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ui): fold superseded drafts and flag unverified answers - answer_superseded folds the streamed draft into a collapsed "draft revised" row; the revised reply renders as the answer. - done.verified == "fail" adds a "✗ unverified" chip to the current turn's answer (never a draft or an earlier turn); a bulk token carrying the verification-failed marker has it stripped in favor of the chip. - History replay folds superseded records and renders persisted markers as the chip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 59a357e commit 614c3f9

19 files changed

Lines changed: 1254 additions & 55 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ cmd/odek/
2424
dispatch.go CLI subcommand dispatch (+ budget.Error → exit code 4 mapping)
2525
shell.go Built-in shell tool (local or docker exec; danger-gated; optional timeout_seconds)
2626
serve.go Web UI server (HTTP + WebSocket; @-resource completion; protocol v2: delta
27-
streaming, ping/pong, WS cancel, session_switch)
27+
streaming, ping/pong, WS cancel, session_switch, answer_superseded + done.verified)
2828
serve_api.go REST management API (/api/health, sessions search/pagination/pin/export,
2929
memory facts + episode promote/discard + consolidate, skills + promote, tools,
3030
profiles, sanitized config view, MCP listing, shutdown)

‎cmd/odek/serve.go‎

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,9 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string,
981981
SetToolOutputGuard(injectionGuard, resolved.Guard)
982982
}
983983

984+
// built is bound once odek.New returns; the answer-event handler only
985+
// fires during runs, which start after that.
986+
var built *odek.Agent
984987
serveCfg := odek.Config{
985988
Model: resolved.Model,
986989
BaseURL: resolved.BaseURL,
@@ -1032,6 +1035,15 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string,
10321035
sendFn(map[string]any{"type": "runtime_event", "event": ev})
10331036
}
10341037
},
1038+
// Final-answer lifecycle, delivered synchronously so it stays in
1039+
// order with token_delta frames and lands before done (the event
1040+
// stream above is asynchronous).
1041+
AnswerEventHandler: serveAnswerEventHandler(sendFn, func() string {
1042+
if built == nil {
1043+
return ""
1044+
}
1045+
return built.RunID()
1046+
}),
10351047
ToolDetailHandler: func(event loop.ToolDetailEvent) {
10361048
sendFn(map[string]any{"type": event.Type, "name": event.Name, "data": event.Data, "call_id": event.CallID, "outcome": event.Outcome})
10371049
},
@@ -1084,6 +1096,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string,
10841096
applyResolvedProvider(&serveCfg, resolved)
10851097
serveCfg.RuntimeLogSurface = "serve"
10861098
agent, err := odek.New(serveCfg)
1099+
built = agent
10871100
if err != nil {
10881101
// Container was started but agent construction failed — clean up now
10891102
// so the container doesn't outlive this call.
@@ -1105,6 +1118,41 @@ func newServeAgent(resolved config.ResolvedConfig, system string, runKey string,
11051118
return agent, bgRT, sandboxCleanup, mcpCleanup, guardCleanup, injectionGuard, approver, nil
11061119
}
11071120

1121+
// serveAnswerEventHandler forwards final-answer lifecycle events to a
1122+
// WebSocket connection as frames (see answerEventFrame).
1123+
func serveAnswerEventHandler(sendFn func(v any) error, runID func() string) func(loop.AnswerEvent) {
1124+
return func(event loop.AnswerEvent) {
1125+
if frame, ok := answerEventFrame(event, runID()); ok {
1126+
sendFn(frame)
1127+
}
1128+
}
1129+
}
1130+
1131+
// answerEventFrame renders a final-answer lifecycle event as a WebSocket
1132+
// frame. answer_superseded goes out only for a draft that was streamed live
1133+
// (a buffered draft never reached the client, and the post-run bulk send
1134+
// skips it). Verification events become runtime_event frames shaped like
1135+
// odek.event/v1; verifier prose is never part of an AnswerEvent.
1136+
func answerEventFrame(ev loop.AnswerEvent, runID string) (map[string]any, bool) {
1137+
switch ev.Type {
1138+
case "answer_superseded":
1139+
if !ev.Streamed {
1140+
return nil, false
1141+
}
1142+
return map[string]any{"type": "answer_superseded", "reason": ev.Reason, "cycle": ev.Cycle}, true
1143+
case events.TypeVerificationStarted, events.TypeVerificationCompleted:
1144+
out := events.Event{Schema: events.Schema, Type: ev.Type, RunID: runID, Timestamp: time.Now().UTC()}
1145+
if ev.Type == events.TypeVerificationCompleted {
1146+
out.Data = map[string]any{"verdict": ev.Verdict, "cycles_used": ev.CyclesUsed}
1147+
if ev.SkippedReason != "" {
1148+
out.Data["skipped_reason"] = ev.SkippedReason
1149+
}
1150+
}
1151+
return map[string]any{"type": "runtime_event", "event": out}, true
1152+
}
1153+
return nil, false
1154+
}
1155+
11081156
// serveDeltaHandler builds the loop DeltaHandler for a serve connection:
11091157
// usageFrame builds a protocol-v2 usage event from an iteration callback.
11101158
// The frame is emitted when the parent window is known or the think step
@@ -1278,14 +1326,15 @@ func newTurnID() string {
12781326
// is active (R3). Lifecycle and sub-agent frames stay untouched so old
12791327
// clients see byte-identical shapes for them.
12801328
var turnTaggedFrames = map[string]bool{
1281-
"thinking": true,
1282-
"token": true,
1283-
"tool_call": true,
1284-
"tool_result": true,
1285-
"runtime_event": true,
1286-
"artifact": true,
1287-
"done": true,
1288-
"error": true,
1329+
"thinking": true,
1330+
"token": true,
1331+
"tool_call": true,
1332+
"tool_result": true,
1333+
"runtime_event": true,
1334+
"answer_superseded": true,
1335+
"artifact": true,
1336+
"done": true,
1337+
"error": true,
12891338
}
12901339

12911340
// wsTurnAnnotator tags outbound frames with the active turn id (R3) so a
@@ -2477,6 +2526,13 @@ func handlePrompt(
24772526
}
24782527
isFinalAnswer := len(msg.ToolCalls) == 0
24792528

2529+
if msg.Superseded {
2530+
// A draft replaced by a re-ask. When it streamed live the
2531+
// client already has it, followed by answer_superseded; when
2532+
// it did not, the client never saw it and only the
2533+
// replacement is sent.
2534+
continue
2535+
}
24802536
if !isFinalAnswer {
24812537
// Intermediate turn — tool_call/tool_result events already streamed.
24822538
// Skip Content to avoid duplicating the narrative after tool blocks.
@@ -2497,13 +2553,11 @@ func handlePrompt(
24972553
}
24982554
}
24992555

2500-
// Find the assistant response for buffer
2556+
// Buffer the turn's answer: the last assistant reply, never a draft the
2557+
// loop superseded.
25012558
if mm := agent.Memory(); mm != nil {
2502-
for _, msg := range newMsgs {
2503-
if msg.Role == "assistant" && msg.Content != "" {
2504-
mm.AppendBuffer("agent", msg.Content)
2505-
break
2506-
}
2559+
if answer, ok := turnAnswer(newMsgs); ok {
2560+
mm.AppendBuffer("agent", answer)
25072561
}
25082562
}
25092563

@@ -2556,6 +2610,9 @@ func handlePrompt(
25562610
if ms := agent.TotalLLMDurationMs(); ms > 0 {
25572611
m["llmDurationMs"] = ms
25582612
}
2613+
if v := agent.VerifyOutcome(); v != "" {
2614+
m["verified"] = v // omitted when verification did not run
2615+
}
25592616
return m
25602617
}())
25612618

@@ -2566,6 +2623,18 @@ func handlePrompt(
25662623
return currSess
25672624
}
25682625

2626+
// turnAnswer returns the turn's answer text: the last assistant message
2627+
// with content that was not superseded by a re-ask.
2628+
func turnAnswer(msgs []session.Message) (string, bool) {
2629+
for i := len(msgs) - 1; i >= 0; i-- {
2630+
m := msgs[i]
2631+
if m.Role == "assistant" && m.Content != "" && !m.Superseded {
2632+
return m.Content, true
2633+
}
2634+
}
2635+
return "", false
2636+
}
2637+
25692638
// ── WebSocket Stream Writer ─────────────────────────────────────────────
25702639

25712640
type wsStreamWriter struct {

0 commit comments

Comments
 (0)