feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events
- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
as intent-holder, holds initial intent, reconciles cross-file contract disputes)
with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
This commit is contained in:
@@ -96,6 +96,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
switch msg.Type {
|
||||
case protocol.TypeSessionAnnounced:
|
||||
m.onSessionAnnounced(msg)
|
||||
case protocol.TypeSessionRenamed:
|
||||
m.onSessionRenamed(msg)
|
||||
case protocol.TypeRouterNarration:
|
||||
entry := RouterEntry{Role: "narration_llm", Content: msg.Content}
|
||||
if msg.LatencyMs != nil && msg.TotalTokens != nil {
|
||||
@@ -198,6 +200,11 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
s.StageTokensUsed = *msg.TotalTokens
|
||||
}
|
||||
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
|
||||
// Thread the model's reasoning trace into the transcript as a collapsed "thinking"
|
||||
// row (revealed via the palette). Skipped when the model emits no separate channel.
|
||||
if strings.TrimSpace(msg.Reasoning) != "" {
|
||||
m.appendRouter(msg.SessionID, RouterEntry{Role: "thinking", Content: msg.Reasoning})
|
||||
}
|
||||
}
|
||||
case protocol.TypeInferenceTimeout:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
@@ -217,7 +224,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
case protocol.TypeToolStarted:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Active = true
|
||||
s.Tools = append(s.Tools, ToolRecord{Name: msg.ToolName, Status: ToolStarted})
|
||||
s.Tools = append(s.Tools, ToolRecord{Name: msg.ToolName, Status: ToolStarted, Params: msg.Params})
|
||||
if len(s.Tools) > 8 {
|
||||
s.Tools = s.Tools[len(s.Tools)-8:]
|
||||
}
|
||||
@@ -240,7 +247,15 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
|
||||
} else {
|
||||
label := msg.ToolName
|
||||
if len(msg.AffectedEntities) > 0 {
|
||||
// Prefer the actual call args (path=…, command="…") over the affected-entities
|
||||
// fallback: params show *what was asked*, not just what got touched.
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
if suffix := paramSuffix(lastToolParams(s, msg.ToolName)); suffix != "" {
|
||||
label += suffix
|
||||
} else if len(msg.AffectedEntities) > 0 {
|
||||
label += " " + strings.Join(msg.AffectedEntities, ", ")
|
||||
}
|
||||
} else if len(msg.AffectedEntities) > 0 {
|
||||
label += " " + strings.Join(msg.AffectedEntities, ", ")
|
||||
}
|
||||
m.appendAction(msg.SessionID, "✓", actionToolText(label, msg.Summary))
|
||||
@@ -542,6 +557,25 @@ func countSuffix(added, removed int) string {
|
||||
return " (+" + itoa(added) + " −" + itoa(removed) + ")"
|
||||
}
|
||||
|
||||
// lastToolParams returns the call args of the most recent invocation of the named tool. The ReAct
|
||||
// loop runs one tool at a time per session, so the last started record is the one now completing.
|
||||
func lastToolParams(s *Session, name string) []string {
|
||||
for i := len(s.Tools) - 1; i >= 0; i-- {
|
||||
if s.Tools[i].Name == name {
|
||||
return s.Tools[i].Params
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paramSuffix renders pretty call args as a " (k=v · k=v)" suffix, or "" when there are none.
|
||||
func paramSuffix(params []string) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (" + clip(strings.Join(params, " · "), 56) + ")"
|
||||
}
|
||||
|
||||
// actionToolText joins a tool label with a short, clipped result summary.
|
||||
func actionToolText(label, summary string) string {
|
||||
// Collapse to a single line first: tool summaries (dir listings, file heads)
|
||||
@@ -589,7 +623,9 @@ func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) {
|
||||
s.Status = "ACTIVE"
|
||||
if msg.WorkflowID != "" {
|
||||
s.WorkflowID = msg.WorkflowID
|
||||
s.Name = msg.WorkflowID
|
||||
if !s.named {
|
||||
s.Name = msg.WorkflowID
|
||||
}
|
||||
}
|
||||
s.LastEventAt = nowMillis()
|
||||
if m.pendingWorkflowFocus {
|
||||
@@ -601,6 +637,19 @@ func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// onSessionRenamed applies the intent-derived title from session.renamed, replacing the
|
||||
// opaque workflow id shown until the naming inference completed. The named flag pins it so a
|
||||
// later announce (e.g. on reconnect replay) doesn't revert the display back to the workflow id.
|
||||
func (m *Model) onSessionRenamed(msg protocol.ServerMessage) {
|
||||
if msg.Name == "" {
|
||||
return
|
||||
}
|
||||
s := m.ensureSession(msg.SessionID)
|
||||
s.Name = msg.Name
|
||||
s.named = true
|
||||
s.LastEventAt = nowMillis()
|
||||
}
|
||||
|
||||
func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
||||
risk := "unknown"
|
||||
var rationale []string
|
||||
@@ -710,6 +759,70 @@ func (m *Model) onSnapshot(msg protocol.ServerMessage) {
|
||||
if m.selectedID == "" {
|
||||
m.selectedID = msg.SessionID
|
||||
}
|
||||
m.restoreTranscript(msg.SessionID, msg.Transcript)
|
||||
}
|
||||
|
||||
// restoreTranscript rebuilds the OUTPUT transcript for a reopened session from the snapshot's raw
|
||||
// source rows, reusing the same formatters the live path uses (diffSummary/paramSuffix/…). Replaces
|
||||
// (not appends) so a reconnect with an existing transcript doesn't duplicate rows.
|
||||
func (m *Model) restoreTranscript(sid string, rows []protocol.TranscriptRowDto) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]RouterEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch r.Kind {
|
||||
case "chat":
|
||||
role := "router"
|
||||
if r.Role == "USER" {
|
||||
role = "user"
|
||||
}
|
||||
e := RouterEntry{Role: role, Content: r.Content}
|
||||
if r.LatencyMs != nil && r.TotalTokens != nil {
|
||||
e.Metrics = &TurnMetrics{LatencyMs: *r.LatencyMs, TotalTokens: *r.TotalTokens}
|
||||
}
|
||||
out = append(out, e)
|
||||
case "narration":
|
||||
e := RouterEntry{Role: "narration_llm", Content: r.Content}
|
||||
if r.LatencyMs != nil && r.TotalTokens != nil {
|
||||
e.Metrics = &TurnMetrics{LatencyMs: *r.LatencyMs, TotalTokens: *r.TotalTokens}
|
||||
}
|
||||
out = append(out, e)
|
||||
case "thinking":
|
||||
if strings.TrimSpace(r.Content) != "" {
|
||||
out = append(out, RouterEntry{Role: "thinking", Content: r.Content})
|
||||
}
|
||||
case "tool":
|
||||
out = append(out, toolTranscriptRows(r)...)
|
||||
}
|
||||
}
|
||||
m.routerMessages[sid] = out
|
||||
}
|
||||
|
||||
// toolTranscriptRows mirrors the live TypeToolCompleted/Failed/Rejected handling: a diff becomes a
|
||||
// ✎ write row + the collapsed raw-diff row; otherwise a single ✓/✗/✕ action row.
|
||||
func toolTranscriptRows(r protocol.TranscriptRowDto) []RouterEntry {
|
||||
if r.Diff != nil && *r.Diff != "" {
|
||||
path, add, del := diffSummary(*r.Diff)
|
||||
return []RouterEntry{
|
||||
{Role: "action", Icon: "✎", Content: "wrote " + path + countSuffix(add, del)},
|
||||
{Role: "tool", Content: *r.Diff},
|
||||
}
|
||||
}
|
||||
switch r.Status {
|
||||
case "failed":
|
||||
return []RouterEntry{{Role: "action", Icon: "✗", Content: actionToolText(r.ToolName+" failed", r.Summary)}}
|
||||
case "rejected":
|
||||
return []RouterEntry{{Role: "action", Icon: "✕", Content: actionToolText(r.ToolName+" blocked", r.Summary)}}
|
||||
default:
|
||||
label := r.ToolName
|
||||
if suffix := paramSuffix(r.Params); suffix != "" {
|
||||
label += suffix
|
||||
} else if len(r.AffectedEntities) > 0 {
|
||||
label += " " + strings.Join(r.AffectedEntities, ", ")
|
||||
}
|
||||
return []RouterEntry{{Role: "action", Icon: "✓", Content: actionToolText(label, r.Summary)}}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) touch(id, status string) {
|
||||
|
||||
Reference in New Issue
Block a user