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:
@@ -54,6 +54,18 @@ data class TierApprovalStatsDto(
|
||||
val avgWaitMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QualityStatsDto(
|
||||
val sourceFetches: Long = 0,
|
||||
val lowQualityExtractions: Long = 0,
|
||||
val extractionQualityRate: Double = 1.0,
|
||||
val retrievedHits: Long = 0,
|
||||
val droppedHits: Long = 0,
|
||||
val retrievalPrecision: Double = 1.0,
|
||||
val briefDriftCount: Long = 0,
|
||||
val capabilityGapCount: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatsReportDto(
|
||||
val sessionId: String,
|
||||
@@ -75,6 +87,7 @@ data class StatsReportDto(
|
||||
val avgApprovalWaitMs: Long = 0,
|
||||
val perTier: List<TierApprovalStatsDto> = emptyList(),
|
||||
val failures: FailureMetricsDto = FailureMetricsDto(),
|
||||
val quality: QualityStatsDto = QualityStatsDto(),
|
||||
val inferencePct: Double = 0.0,
|
||||
val toolPct: Double = 0.0,
|
||||
val approvalWaitPct: Double = 0.0,
|
||||
@@ -146,6 +159,17 @@ fun renderStats(report: StatsReportDto): String {
|
||||
"stage: ${f.stageFailures} workflow: ${f.workflowFailures}"
|
||||
lines += ""
|
||||
|
||||
val q = report.quality
|
||||
lines += "Signal quality"
|
||||
lines += " extraction: %d fetched, %d low-quality (%.0f%% clean)".format(
|
||||
Locale.ROOT, q.sourceFetches, q.lowQualityExtractions, q.extractionQualityRate * 100.0,
|
||||
)
|
||||
lines += " retrieval: %d kept, %d filtered (%.0f%% precision)".format(
|
||||
Locale.ROOT, q.retrievedHits, q.droppedHits, q.retrievalPrecision * 100.0,
|
||||
)
|
||||
lines += " brief drift: ${q.briefDriftCount} capability gaps: ${q.capabilityGapCount}"
|
||||
lines += ""
|
||||
|
||||
lines += "Time accounting (% of session wall time)"
|
||||
lines += " inference: %.1f%% tools: %.1f%% approval-wait: %.1f%% other: %.1f%%".format(
|
||||
Locale.ROOT,
|
||||
|
||||
@@ -82,6 +82,7 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.infrastructure.InfrastructureModule
|
||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||
import com.correx.infrastructure.workflow.PlanLinter
|
||||
import com.correx.infrastructure.inference.CapabilityAwareRoutingStrategy
|
||||
import com.correx.infrastructure.inference.commons.ManagedInferenceRouter
|
||||
import com.correx.infrastructure.inference.commons.AmdResourceProbe
|
||||
@@ -388,7 +389,17 @@ fun main() {
|
||||
repositories = repositories,
|
||||
engines = engines.copy(
|
||||
planCompilationCheck = { json, candidateId ->
|
||||
runCatching { planCompiler.compile(json, candidateId) }.exceptionOrNull()?.message
|
||||
// Compile first; a compile failure returns its message. If it compiles, also run the
|
||||
// deterministic lint here so a hard lint failure (unproduced need, trap state) feeds
|
||||
// back to the architect via the same retry loop — rather than the post-planning
|
||||
// FreestyleDriver lint rejecting it after the session already reached COMPLETED.
|
||||
val compiled = runCatching { planCompiler.compile(json, candidateId) }
|
||||
compiled.exceptionOrNull()?.message
|
||||
?: PlanLinter.lint(compiled.getOrThrow()).takeIf { !it.passed }?.let { lint ->
|
||||
"plan lint failed: " + lint.hardFailures.joinToString("; ") {
|
||||
"${it.code}${it.stageId?.let { s -> " @$s" } ?: ""}: ${it.detail}"
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.SessionNamedEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
@@ -90,6 +91,13 @@ suspend fun domainEventToServerMessage(
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is SessionNamedEvent -> ServerMessage.SessionRenamed(
|
||||
sessionId = p.sessionId,
|
||||
name = p.name,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
is ChatTurnEvent -> ServerMessage.ChatTurn(
|
||||
sessionId = p.sessionId,
|
||||
turnId = p.turnId,
|
||||
@@ -182,6 +190,7 @@ suspend fun domainEventToServerMessage(
|
||||
sessionId = p.sessionId,
|
||||
toolName = p.toolName,
|
||||
tier = p.tier,
|
||||
params = prettyToolParams(p.request.parameters),
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
@@ -354,14 +363,19 @@ private suspend fun mapInferenceCompleted(
|
||||
p: InferenceCompletedEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
sessionSequence: Long,
|
||||
): ServerMessage = runCatching {
|
||||
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrElse { "" }.run {
|
||||
ServerMessage.InferenceCompleted(
|
||||
): ServerMessage {
|
||||
val response = runCatching {
|
||||
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrElse { "" }
|
||||
val reasoning = p.reasoningArtifactId?.let { id ->
|
||||
runCatching { artifactStore.get(id)?.toString(Charsets.UTF_8) ?: "" }.getOrElse { "" }
|
||||
} ?: ""
|
||||
return ServerMessage.InferenceCompleted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
outputSummary = this,
|
||||
responseText = this,
|
||||
outputSummary = response,
|
||||
responseText = response,
|
||||
reasoning = reasoning,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
totalTokens = p.tokensUsed.totalTokens,
|
||||
sequence = event.sequence,
|
||||
@@ -389,3 +403,33 @@ private fun mapApprovalRequested(
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
/**
|
||||
* Render a tool call's raw parameter map into compact, human-readable "key=value" cells for the
|
||||
* client's tool-call row. Primary keys (path/command/query/…) lead so the most salient argument is
|
||||
* first; long values are truncated and multi-line values collapsed so the WS frame stays small.
|
||||
*/
|
||||
internal fun prettyToolParams(parameters: Map<String, Any>): List<String> {
|
||||
if (parameters.isEmpty()) return emptyList()
|
||||
val primary = listOf("command", "path", "query", "url", "pattern", "content", "operation")
|
||||
val ordered = parameters.entries.sortedWith(
|
||||
compareBy({ primary.indexOf(it.key).let { i -> if (i < 0) primary.size else i } }, { it.key }),
|
||||
)
|
||||
return ordered.take(MAX_PARAM_CELLS).map { (k, v) -> "$k=${formatParamValue(v)}" }
|
||||
}
|
||||
|
||||
private fun formatParamValue(value: Any?): String {
|
||||
val raw = when (value) {
|
||||
null -> "null"
|
||||
is String -> value
|
||||
is Collection<*> -> value.joinToString(", ", prefix = "[", postfix = "]") { formatParamValue(it) }
|
||||
else -> value.toString()
|
||||
}
|
||||
val flattened = raw.replace('\n', ' ').replace('\r', ' ').trim()
|
||||
val clipped = if (flattened.length > MAX_PARAM_VALUE_LEN) flattened.take(MAX_PARAM_VALUE_LEN) + "…" else flattened
|
||||
// Quote strings that carry whitespace so the boundary of the value is unambiguous in the row.
|
||||
return if (value is String && clipped.any { it.isWhitespace() }) "\"$clipped\"" else clipped
|
||||
}
|
||||
|
||||
private const val MAX_PARAM_CELLS = 5
|
||||
private const val MAX_PARAM_VALUE_LEN = 80
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.correx.apps.server.protocol.SessionStateDto
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.apps.server.protocol.ToolRecordDto
|
||||
import com.correx.apps.server.protocol.TranscriptRowDto
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
@@ -20,6 +21,7 @@ import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
@@ -28,6 +30,7 @@ import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TalkieNarrationEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
@@ -166,6 +169,7 @@ class SessionEventBridge(
|
||||
}
|
||||
|
||||
val toolRecords = rebuildTools(events)
|
||||
val transcript = reconstructTranscript(events)
|
||||
|
||||
send(ServerMessage.SessionSnapshot(
|
||||
sessionId = sessionId,
|
||||
@@ -178,6 +182,7 @@ class SessionEventBridge(
|
||||
pendingApprovals = pendingApprovals,
|
||||
tools = toolRecords,
|
||||
recentEvents = recentEvents,
|
||||
transcript = transcript,
|
||||
lastOutput = lastOutput,
|
||||
lastResponse = lastOutput,
|
||||
workspaceRoot = workspaceRoot,
|
||||
@@ -233,6 +238,67 @@ class SessionEventBridge(
|
||||
}?.let { stored -> mapper.map(stored, stored.sessionSequence)?.let { send(it) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the OUTPUT-transcript source rows (chat / narration / thinking / tool) so a reopened
|
||||
* session restores its full conversation. Ships raw content only — the client applies its own
|
||||
* diff/param/action formatting. Reasoning is resolved from CAS (same as the live path). Tool
|
||||
* params are paired to their completion by invocation id.
|
||||
*/
|
||||
private suspend fun reconstructTranscript(events: List<StoredEvent>): List<TranscriptRowDto> {
|
||||
val paramsByInvocation = mutableMapOf<String, List<String>>()
|
||||
val rows = mutableListOf<TranscriptRowDto>()
|
||||
for (event in events) {
|
||||
when (val p = event.payload) {
|
||||
is ChatTurnEvent -> rows.add(
|
||||
TranscriptRowDto(
|
||||
kind = "chat",
|
||||
role = p.role.name,
|
||||
content = p.content,
|
||||
latencyMs = p.latencyMs,
|
||||
totalTokens = p.tokensUsed?.totalTokens,
|
||||
),
|
||||
)
|
||||
is TalkieNarrationEvent -> rows.add(
|
||||
TranscriptRowDto(
|
||||
kind = "narration",
|
||||
content = p.content,
|
||||
latencyMs = p.latencyMs,
|
||||
totalTokens = p.tokensUsed?.totalTokens,
|
||||
),
|
||||
)
|
||||
is InferenceCompletedEvent -> {
|
||||
val reasoning = p.reasoningArtifactId
|
||||
?.let { runCatching { artifactStore.get(it)?.toString(Charsets.UTF_8) }.getOrNull() }
|
||||
.orEmpty()
|
||||
if (reasoning.isNotBlank()) {
|
||||
rows.add(TranscriptRowDto(kind = "thinking", content = reasoning))
|
||||
}
|
||||
}
|
||||
is ToolInvocationRequestedEvent ->
|
||||
paramsByInvocation[p.invocationId.value] = prettyToolParams(p.request.parameters)
|
||||
is ToolExecutionCompletedEvent -> rows.add(
|
||||
TranscriptRowDto(
|
||||
kind = "tool",
|
||||
status = "completed",
|
||||
toolName = p.toolName,
|
||||
diff = p.receipt.diff,
|
||||
params = paramsByInvocation[p.invocationId.value].orEmpty(),
|
||||
summary = p.receipt.outputSummary,
|
||||
affectedEntities = p.receipt.affectedEntities,
|
||||
),
|
||||
)
|
||||
is ToolExecutionFailedEvent -> rows.add(
|
||||
TranscriptRowDto(kind = "tool", status = "failed", toolName = p.toolName, summary = p.reason),
|
||||
)
|
||||
is ToolExecutionRejectedEvent -> rows.add(
|
||||
TranscriptRowDto(kind = "tool", status = "rejected", toolName = p.toolName, summary = p.reason),
|
||||
)
|
||||
else -> { /* not a transcript row */ }
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private fun eventToEntry(event: StoredEvent): EventEntryDto? {
|
||||
val ts = event.metadata.timestamp.toEpochMilliseconds()
|
||||
return when (val p = event.payload) {
|
||||
|
||||
+36
@@ -34,6 +34,24 @@ data class TierApprovalStatsDto(
|
||||
val avgWaitMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Signal-quality view: raw counts plus the two derived ratios an operator reads. Extraction
|
||||
* quality is the share of research fetches that cleared the quality bar; retrieval precision is
|
||||
* the share of repo-knowledge hits kept (not filtered below the similarity threshold). Both are
|
||||
* 1.0 when there's nothing to judge (no fetches / no retrieval), so a clean run reads as clean.
|
||||
*/
|
||||
@Serializable
|
||||
data class QualityStatsDto(
|
||||
val sourceFetches: Long = 0,
|
||||
val lowQualityExtractions: Long = 0,
|
||||
val extractionQualityRate: Double = 1.0,
|
||||
val retrievedHits: Long = 0,
|
||||
val droppedHits: Long = 0,
|
||||
val retrievalPrecision: Double = 1.0,
|
||||
val briefDriftCount: Long = 0,
|
||||
val capabilityGapCount: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Render-friendly view of a session's metrics. Raw sums plus the derived ratios the operator
|
||||
* actually reads; the time-accounting percentages split session wall time into inference /
|
||||
@@ -61,6 +79,7 @@ data class MetricsReport(
|
||||
val avgApprovalWaitMs: Long,
|
||||
val perTier: List<TierApprovalStatsDto>,
|
||||
val failures: FailureMetrics,
|
||||
val quality: QualityStatsDto = QualityStatsDto(),
|
||||
val inferencePct: Double,
|
||||
val toolPct: Double,
|
||||
val approvalWaitPct: Double,
|
||||
@@ -125,6 +144,7 @@ class MetricsInspectionService(private val eventStore: EventStore) {
|
||||
)
|
||||
},
|
||||
failures = s.failures,
|
||||
quality = qualityStats(s.quality),
|
||||
inferencePct = pct(s.inference.totalLatencyMs, durationMs),
|
||||
toolPct = pct(s.tools.totalDurationMs, durationMs),
|
||||
approvalWaitPct = pct(s.approvals.totalWaitMs, durationMs),
|
||||
@@ -145,4 +165,20 @@ class MetricsInspectionService(private val eventStore: EventStore) {
|
||||
|
||||
private fun avg(total: Long, count: Long): Long =
|
||||
if (count <= 0L) 0L else total / count
|
||||
|
||||
private fun qualityStats(q: QualityMetrics): QualityStatsDto {
|
||||
val keptExtractions = (q.sourceFetches - q.lowQualityExtractions).coerceAtLeast(0L)
|
||||
val totalHits = q.retrievedHits + q.droppedHits
|
||||
return QualityStatsDto(
|
||||
sourceFetches = q.sourceFetches,
|
||||
lowQualityExtractions = q.lowQualityExtractions,
|
||||
// 1.0 when nothing was fetched — no evidence of low quality reads as clean.
|
||||
extractionQualityRate = if (q.sourceFetches <= 0L) 1.0 else keptExtractions.toDouble() / q.sourceFetches,
|
||||
retrievedHits = q.retrievedHits,
|
||||
droppedHits = q.droppedHits,
|
||||
retrievalPrecision = if (totalHits <= 0L) 1.0 else q.retrievedHits.toDouble() / totalHits,
|
||||
briefDriftCount = q.briefDriftCount,
|
||||
capabilityGapCount = q.capabilityGapCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,17 @@ package com.correx.apps.server.metrics
|
||||
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.CapabilityGapReflectedEvent
|
||||
import com.correx.core.events.events.CapabilityGapVerdict
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.LowQualityExtractionEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeHitsFilteredEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
|
||||
import com.correx.core.events.events.SourceFetchedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
@@ -44,13 +52,37 @@ class MetricsProjection : Projection<MetricsState> {
|
||||
is ApprovalDecisionResolvedEvent -> spanned.applyApprovalResolved(payload)
|
||||
is StageFailedEvent -> spanned.bumpFailure { it.copy(stageFailures = it.stageFailures + 1) }
|
||||
is WorkflowFailedEvent -> spanned.bumpFailure { it.copy(workflowFailures = it.workflowFailures + 1) }
|
||||
else -> spanned
|
||||
else -> spanned.applyQualityEvent(payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Signal-quality events other bounded contexts emit. Split out of apply() so its when stays
|
||||
// under the cyclomatic-complexity bar; unmatched payloads fall through unchanged.
|
||||
private fun MetricsState.applyQualityEvent(payload: EventPayload): MetricsState =
|
||||
when (payload) {
|
||||
is SourceFetchedEvent -> bumpQuality { it.copy(sourceFetches = it.sourceFetches + 1) }
|
||||
is LowQualityExtractionEvent ->
|
||||
bumpQuality { it.copy(lowQualityExtractions = it.lowQualityExtractions + 1) }
|
||||
is RepoKnowledgeRetrievedEvent ->
|
||||
bumpQuality { it.copy(retrievedHits = it.retrievedHits + payload.hits.size) }
|
||||
is RepoKnowledgeHitsFilteredEvent ->
|
||||
bumpQuality { it.copy(droppedHits = it.droppedHits + payload.dropped.size) }
|
||||
is BriefEchoMismatchEvent -> bumpQuality { it.copy(briefDriftCount = it.briefDriftCount + 1) }
|
||||
is CapabilityGapReflectedEvent ->
|
||||
if (payload.verdict == CapabilityGapVerdict.NEEDS_TOOL) {
|
||||
bumpQuality { it.copy(capabilityGapCount = it.capabilityGapCount + 1) }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
else -> this
|
||||
}
|
||||
|
||||
private fun MetricsState.bumpFailure(transform: (FailureMetrics) -> FailureMetrics): MetricsState =
|
||||
copy(failures = transform(failures))
|
||||
|
||||
private fun MetricsState.bumpQuality(transform: (QualityMetrics) -> QualityMetrics): MetricsState =
|
||||
copy(quality = transform(quality))
|
||||
|
||||
private fun MetricsState.applyInferenceCompleted(e: InferenceCompletedEvent): MetricsState {
|
||||
val provider = e.providerId.value
|
||||
val prev = inference.perProvider[provider] ?: ProviderInferenceMetrics()
|
||||
|
||||
@@ -20,6 +20,7 @@ data class MetricsState(
|
||||
val tools: ToolMetrics = ToolMetrics(),
|
||||
val approvals: ApprovalMetrics = ApprovalMetrics(),
|
||||
val failures: FailureMetrics = FailureMetrics(),
|
||||
val quality: QualityMetrics = QualityMetrics(),
|
||||
/**
|
||||
* In-flight approval requests awaiting resolution: requestId → request-event timestamp.
|
||||
* Transient correlation bookkeeping that lets request→decision latency be computed in a
|
||||
@@ -74,6 +75,21 @@ data class TierApprovalMetrics(
|
||||
val totalWaitMs: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Signal-quality accumulators — raw counts of the quality/rate events other bounded contexts emit.
|
||||
* Ratios (extraction-quality, retrieval-precision) are derived at render time from these sums, the
|
||||
* same way inference throughput is, so this stays a stable replayable accumulator.
|
||||
*/
|
||||
@Serializable
|
||||
data class QualityMetrics(
|
||||
val sourceFetches: Long = 0, // total research extractions (extraction-quality denominator)
|
||||
val lowQualityExtractions: Long = 0, // extractions flagged below the quality bar
|
||||
val retrievedHits: Long = 0, // repo-knowledge hits surfaced to a stage
|
||||
val droppedHits: Long = 0, // repo-knowledge hits filtered below the similarity threshold
|
||||
val briefDriftCount: Long = 0, // brief-echo mismatches (uncovered reqs / hallucinated refs)
|
||||
val capabilityGapCount: Long = 0, // NEEDS_TOOL capability-gap reflections (genuine gaps only)
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FailureMetrics(
|
||||
val inferenceFailures: Long = 0,
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.correx.apps.server.narration
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.InitialIntentEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.ReviewFindingsRaisedEvent
|
||||
import com.correx.core.events.events.ReviewVerdict
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
import com.correx.core.events.events.StageFailedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
@@ -63,6 +66,16 @@ class NarrationSubscriber(
|
||||
val sid = event.metadata.sessionId
|
||||
when (val p = event.payload) {
|
||||
is WorkflowStartedEvent -> lanes[sid.value]?.let { it.used = 0 }
|
||||
// Derive a human title from the opening intent, off the narration budget/lane (it's not
|
||||
// a narration and must not consume a run's narration allowance). Launched so a slow name
|
||||
// inference never stalls the event stream; failure is non-fatal (session keeps its id).
|
||||
is InitialIntentEvent -> scope.launch {
|
||||
runCatching { routerFacade.nameSession(sid, p.intent) }
|
||||
.onFailure { e ->
|
||||
if (e is CancellationException) throw e
|
||||
log.warn("session naming failed for {}: {}", sid.value, e.message)
|
||||
}
|
||||
}
|
||||
// Approval pauses narrate off this event, not OrchestrationPaused: it carries the
|
||||
// tool/tier/preview facts directly, where the paused event forced a racy lookup
|
||||
// against state the next event had not yet written.
|
||||
@@ -107,6 +120,19 @@ class NarrationSubscriber(
|
||||
stageId = p.stageId.value,
|
||||
),
|
||||
)
|
||||
// Surface the semantic reviewer's verdict conversationally instead of a raw findings
|
||||
// dump: the narrator turns "FAIL, 2 findings" into a plain-language explanation the
|
||||
// operator can act on. Only narrate when there's something to say (non-PASS or findings).
|
||||
is ReviewFindingsRaisedEvent -> if (p.verdict != ReviewVerdict.PASS || p.findings.isNotEmpty()) {
|
||||
enqueue(
|
||||
sid,
|
||||
NarrationTrigger(
|
||||
kind = "review",
|
||||
instruction = reviewInstruction(p),
|
||||
stageId = p.stageId.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
is ExecutionPlanRejectedEvent -> enqueue(
|
||||
sid,
|
||||
NarrationTrigger(
|
||||
@@ -153,6 +179,25 @@ class NarrationSubscriber(
|
||||
}
|
||||
}
|
||||
|
||||
private fun reviewInstruction(event: ReviewFindingsRaisedEvent): String = buildString {
|
||||
val verdict = event.verdict.name
|
||||
append("The semantic reviewer returned $verdict for stage ${event.stageId.value}")
|
||||
if (event.blocked) append(" (blocking — the stage will retry with this feedback)")
|
||||
append(". ")
|
||||
if (event.findings.isEmpty()) {
|
||||
append("It raised no specific findings. Tell the operator the review result plainly.")
|
||||
} else {
|
||||
append("Summarise its findings for the operator in plain language:\n")
|
||||
event.findings.take(MAX_REVIEW_FINDINGS).forEach { f ->
|
||||
val loc = if (f.target.isNotBlank()) " [${f.target}]" else ""
|
||||
appendLine("- ${f.severity} ${f.category}$loc: ${f.message.take(MAX_FINDING_CHARS)}")
|
||||
}
|
||||
if (event.findings.size > MAX_REVIEW_FINDINGS) {
|
||||
appendLine("- …and ${event.findings.size - MAX_REVIEW_FINDINGS} more.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueue(sessionId: SessionId, trigger: NarrationTrigger, pauseKey: String? = null) {
|
||||
val lane = lanes.computeIfAbsent(sessionId.value) { startLane(sessionId) }
|
||||
if (lane.used >= maxPerRun) {
|
||||
@@ -211,6 +256,8 @@ class NarrationSubscriber(
|
||||
companion object {
|
||||
private const val DEFAULT_MAX_PER_RUN = 100
|
||||
private const val MAX_PREVIEW_CHARS = 800
|
||||
private const val MAX_REVIEW_FINDINGS = 5
|
||||
private const val MAX_FINDING_CHARS = 200
|
||||
|
||||
/** Supersession scope for pauses with no approval request (e.g. USER_REQUESTED). */
|
||||
private const val SESSION_PAUSE_KEY = "__session_pause__"
|
||||
|
||||
@@ -84,6 +84,28 @@ data class EventEntryDto(
|
||||
val detail: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* One raw OUTPUT-transcript source row for a reopened session. The server ships the *content*
|
||||
* (chat text, narration, reasoning, tool diff/params/summary) with no formatting; the client folds
|
||||
* these into its transcript rows in onSnapshot reusing its own diff/param/action formatters, so the
|
||||
* row-rendering logic stays in one place (the Go client) and a restart restores the full transcript
|
||||
* instead of only the thin EVENTS markers. [kind] is chat | narration | thinking | tool.
|
||||
*/
|
||||
@Serializable
|
||||
data class TranscriptRowDto(
|
||||
val kind: String,
|
||||
val role: String = "",
|
||||
val content: String = "",
|
||||
val toolName: String = "",
|
||||
val status: String = "",
|
||||
val diff: String? = null,
|
||||
val params: List<String> = emptyList(),
|
||||
val summary: String = "",
|
||||
val affectedEntities: List<String> = emptyList(),
|
||||
val latencyMs: Long? = null,
|
||||
val totalTokens: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ToolRecordDto(
|
||||
val name: String,
|
||||
|
||||
@@ -51,6 +51,17 @@ sealed interface ServerMessage {
|
||||
override val sessionSequence: Long,
|
||||
) : ServerMessage, SessionMessage
|
||||
|
||||
/** A human-readable title derived from the session's intent, from SessionNamedEvent — the
|
||||
* client shows this in place of the opaque workflow id. */
|
||||
@Serializable
|
||||
@SerialName("session.renamed")
|
||||
data class SessionRenamed(
|
||||
val sessionId: SessionId,
|
||||
val name: String,
|
||||
override val sequence: Long,
|
||||
override val sessionSequence: Long,
|
||||
) : ServerMessage, SessionMessage
|
||||
|
||||
/** The workspace root a session was bound to, from SessionWorkspaceBoundEvent — shown as cwd. */
|
||||
@Serializable
|
||||
@SerialName("session.workspace_bound")
|
||||
@@ -124,6 +135,9 @@ sealed interface ServerMessage {
|
||||
val pendingApprovals: List<ApprovalDto>,
|
||||
val tools: List<ToolRecordDto> = emptyList(),
|
||||
val recentEvents: List<EventEntryDto> = emptyList(),
|
||||
// Raw source for the OUTPUT transcript (chat / narration / thinking / tool rows); the client
|
||||
// folds these into its transcript on reopen so a restart restores the full conversation.
|
||||
val transcript: List<TranscriptRowDto> = emptyList(),
|
||||
// Last stage's inference output, resolved from CAS, so a reopened session restores its
|
||||
// output panel instead of showing blank until new activity arrives.
|
||||
val lastOutput: String = "",
|
||||
@@ -188,6 +202,10 @@ sealed interface ServerMessage {
|
||||
val stageId: StageId,
|
||||
val outputSummary: String,
|
||||
val responseText: String = "",
|
||||
// The model's reasoning/thinking trace (llama `reasoning_content`), when the provider
|
||||
// captured one. Sent so the client can offer a collapsed, opt-in "thinking" view; empty
|
||||
// for models that don't emit a separate reasoning channel.
|
||||
val reasoning: String = "",
|
||||
val occurredAt: Long,
|
||||
val totalTokens: Int? = null,
|
||||
override val sequence: Long,
|
||||
@@ -234,6 +252,10 @@ sealed interface ServerMessage {
|
||||
val sessionId: SessionId,
|
||||
val toolName: String,
|
||||
val tier: Tier,
|
||||
// Pretty-formatted "key=value" call arguments (path=…, command="…", query=…) so the client
|
||||
// can show what the tool was actually invoked with, not just its name. Capped + truncated
|
||||
// upstream (see prettyToolParams) to keep the stream small.
|
||||
val params: List<String> = emptyList(),
|
||||
override val sequence: Long,
|
||||
override val sessionSequence: Long,
|
||||
) : ServerMessage, SessionMessage
|
||||
|
||||
@@ -52,6 +52,7 @@ data class StartSessionRequest(
|
||||
data class SessionSummaryResponse(
|
||||
val sessionId: String,
|
||||
val status: String,
|
||||
val name: String? = null,
|
||||
val intent: String? = null,
|
||||
val workflowId: String? = null,
|
||||
val stageCount: Int = 0,
|
||||
@@ -84,6 +85,7 @@ fun Route.sessionRoutes(module: ServerModule) {
|
||||
SessionSummaryResponse(
|
||||
sessionId = s.sessionId.value,
|
||||
status = s.status,
|
||||
name = s.name,
|
||||
intent = s.intent,
|
||||
workflowId = s.workflowId,
|
||||
stageCount = s.stageCount,
|
||||
|
||||
@@ -260,7 +260,7 @@ class DomainEventMapperTest {
|
||||
val result = domainEventToServerMessage(event, store, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.InferenceCompleted(
|
||||
sessionId, stageId, responseText, responseText, occurredAt,
|
||||
sessionId, stageId, responseText, responseText, occurredAt = occurredAt,
|
||||
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
|
||||
),
|
||||
result,
|
||||
@@ -283,7 +283,7 @@ class DomainEventMapperTest {
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.InferenceCompleted(
|
||||
sessionId, stageId, "", "", occurredAt,
|
||||
sessionId, stageId, "", "", occurredAt = occurredAt,
|
||||
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
|
||||
),
|
||||
result,
|
||||
@@ -325,7 +325,7 @@ class DomainEventMapperTest {
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2, event.sequence, 0L),
|
||||
ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2, sequence = event.sequence, sessionSequence = 0L),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,15 @@ package com.correx.apps.server.bridge
|
||||
import com.correx.apps.server.protocol.EventEntryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.ChatTurnRole
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.TalkieNarrationEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationAnswer
|
||||
import com.correx.core.events.events.ClarificationQuestion
|
||||
@@ -23,8 +30,12 @@ import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ClarificationRequestId
|
||||
import com.correx.core.inference.TokenUsage
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
@@ -228,6 +239,67 @@ class SessionEventBridgeTest {
|
||||
assertEquals(ServerMessage.SnapshotComplete, sent[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaySnapshot reconstructs the OUTPUT transcript with reasoning and a write row`() = runTest {
|
||||
val reasoningId = ArtifactId("reasoning-1")
|
||||
val artifactStore = object : ArtifactStore {
|
||||
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("art-1")
|
||||
override suspend fun get(id: ArtifactId): ByteArray? =
|
||||
if (id == reasoningId) "thinking hard".toByteArray() else null
|
||||
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||
}
|
||||
val diff = "--- a/app.css\n+++ b/app.css\n+body {}\n"
|
||||
val receipt = ToolReceipt(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
toolName = "file_write",
|
||||
exitCode = 0,
|
||||
outputSummary = "wrote 1 line",
|
||||
durationMs = 5L,
|
||||
tier = Tier.T2,
|
||||
timestamp = timestamp,
|
||||
diff = diff,
|
||||
)
|
||||
val events = listOf(
|
||||
storedEvent(ChatTurnEvent(sessionId, "t1", ChatTurnRole.USER, "hi", timestampMs = 0L), seq = 1L),
|
||||
storedEvent(
|
||||
TalkieNarrationEvent(sessionId, "n1", "stage", stageId.value, "narrating", timestampMs = 0L),
|
||||
seq = 2L,
|
||||
),
|
||||
storedEvent(
|
||||
InferenceCompletedEvent(
|
||||
requestId = InferenceRequestId("r1"),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = ProviderId("p"),
|
||||
tokensUsed = TokenUsage(1, 1),
|
||||
latencyMs = 3L,
|
||||
responseArtifactId = ArtifactId("resp-1"),
|
||||
reasoningArtifactId = reasoningId,
|
||||
),
|
||||
seq = 3L,
|
||||
),
|
||||
storedEvent(
|
||||
ToolExecutionCompletedEvent(ToolInvocationId("inv-1"), sessionId, "file_write", receipt),
|
||||
seq = 4L,
|
||||
),
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(
|
||||
store, artifactStore, activeOrchestrationRepository(),
|
||||
noopWorkflowRegistry, noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
|
||||
val transcript = (sent[0] as ServerMessage.SessionSnapshot).transcript
|
||||
assertEquals(listOf("chat", "narration", "thinking", "tool"), transcript.map { it.kind })
|
||||
assertEquals("USER", transcript[0].role)
|
||||
assertEquals("thinking hard", transcript[2].content)
|
||||
assertEquals(diff, transcript[3].diff)
|
||||
assertEquals("wrote 1 line", transcript[3].summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaySnapshot populates recentEvents from event store`() = runTest {
|
||||
val events = listOf(
|
||||
|
||||
@@ -5,11 +5,19 @@ import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.CapabilityGapReflectedEvent
|
||||
import com.correx.core.events.events.CapabilityGapVerdict
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
import com.correx.core.events.events.LowQualityExtractionEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeHit
|
||||
import com.correx.core.events.events.RepoKnowledgeHitsFilteredEvent
|
||||
import com.correx.core.events.events.RepoKnowledgeRetrievedEvent
|
||||
import com.correx.core.events.events.SourceFetchedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
@@ -254,4 +262,43 @@ class MetricsProjectionTest {
|
||||
assertEquals(0.0, r.tokensPerSecond, 0.001)
|
||||
assertEquals(0.0, r.inferencePct, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quality events derive extraction quality, retrieval precision, and drift-gap counts`() {
|
||||
val hit = { p: String -> RepoKnowledgeHit(path = p, text = "t", score = 0.7f) }
|
||||
val qualitySeed = listOf(
|
||||
stored(SourceFetchedEvent(sessionId, stageId, "u1", "sha1", quality = "ok"), 1L, at(1)),
|
||||
stored(SourceFetchedEvent(sessionId, stageId, "u2", "sha2", quality = "ok"), 2L, at(2)),
|
||||
stored(SourceFetchedEvent(sessionId, stageId, "u3", "sha3", quality = "low"), 3L, at(3)),
|
||||
stored(LowQualityExtractionEvent(sessionId, stageId, "u3", "sha3", "paywall"), 4L, at(4)),
|
||||
stored(
|
||||
RepoKnowledgeRetrievedEvent(sessionId, stageId, "q", listOf(hit("a"), hit("b"), hit("c"))),
|
||||
5L, at(5),
|
||||
),
|
||||
stored(
|
||||
RepoKnowledgeHitsFilteredEvent(sessionId, "q", threshold = 0.5f, dropped = listOf(hit("d"))),
|
||||
6L, at(6),
|
||||
),
|
||||
stored(BriefEchoMismatchEvent(sessionId, stageId, listOf("req1"), emptyList(), listOf("Foo")), 7L, at(7)),
|
||||
stored(
|
||||
CapabilityGapReflectedEvent(sessionId, stageId, "file_edit", CapabilityGapVerdict.NEEDS_TOOL, "d", 0L),
|
||||
8L, at(8),
|
||||
),
|
||||
// RESOLVED = self-corrected, not a genuine gap — must not count.
|
||||
stored(
|
||||
CapabilityGapReflectedEvent(sessionId, stageId, "file_write", CapabilityGapVerdict.RESOLVED, "d", 0L),
|
||||
9L, at(9),
|
||||
),
|
||||
)
|
||||
val q = MetricsInspectionService(SeededEventStore(qualitySeed)).inspect(sessionId).quality
|
||||
|
||||
assertEquals(3L, q.sourceFetches)
|
||||
assertEquals(1L, q.lowQualityExtractions)
|
||||
assertEquals(2.0 / 3.0, q.extractionQualityRate, 0.001)
|
||||
assertEquals(3L, q.retrievedHits)
|
||||
assertEquals(1L, q.droppedHits)
|
||||
assertEquals(0.75, q.retrievalPrecision, 0.001)
|
||||
assertEquals(1L, q.briefDriftCount)
|
||||
assertEquals(1L, q.capabilityGapCount)
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -81,6 +81,8 @@ class NarrationSubscriberTest {
|
||||
override suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger) {
|
||||
calls.add(sessionId to trigger)
|
||||
}
|
||||
|
||||
override suspend fun nameSession(sessionId: SessionId, intent: String) = Unit
|
||||
}
|
||||
|
||||
private fun storedEvent(payload: EventPayload, seq: Long): StoredEvent = StoredEvent(
|
||||
@@ -191,6 +193,8 @@ class NarrationSubscriberTest {
|
||||
if (callCount == 1) throw RuntimeException("simulated failure")
|
||||
calls.add(trigger.kind)
|
||||
}
|
||||
|
||||
override suspend fun nameSession(sessionId: SessionId, intent: String) = Unit
|
||||
}
|
||||
NarrationSubscriber(fakeStore, facade, scope).start()
|
||||
|
||||
@@ -263,6 +267,8 @@ class NarrationSubscriberTest {
|
||||
}
|
||||
calls.add(trigger)
|
||||
}
|
||||
|
||||
override suspend fun nameSession(sessionId: SessionId, intent: String) = Unit
|
||||
}
|
||||
|
||||
private fun approvalRequested(requestId: String): ApprovalRequestedEvent = ApprovalRequestedEvent(
|
||||
|
||||
@@ -119,6 +119,7 @@ type ToolRecord struct {
|
||||
Name string
|
||||
Tier int
|
||||
Status ToolStatus
|
||||
Params []string // pretty "key=value" call args, from tool.started
|
||||
}
|
||||
|
||||
// ManifestTool is a declared (not-yet-run) tool from a stage manifest.
|
||||
@@ -208,6 +209,7 @@ type Session struct {
|
||||
Status string
|
||||
WorkflowID string
|
||||
Name string
|
||||
named bool // Name came from a session.renamed (intent-derived title); don't clobber with workflowId
|
||||
LastEventAt int64
|
||||
CurrentStage string
|
||||
PlanGoal string
|
||||
@@ -429,6 +431,11 @@ type Model struct {
|
||||
// the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json.
|
||||
actionsHidden bool
|
||||
|
||||
// thinkingShown reveals the model's reasoning/thinking blocks in the OUTPUT transcript.
|
||||
// Off by default (collapsed to a one-line summary) so the trace doesn't drown the answer;
|
||||
// toggled from the palette ("thinking"). Persisted in tui-prefs.json.
|
||||
thinkingShown bool
|
||||
|
||||
// outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled
|
||||
// (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back.
|
||||
outputScroll int
|
||||
@@ -504,6 +511,7 @@ func NewModel(client *ws.Client) Model {
|
||||
transcriptSel: -1,
|
||||
sbHidden: loadStatusbarHidden(),
|
||||
actionsHidden: loadPrefs().InlineActionsHidden,
|
||||
thinkingShown: loadPrefs().ThinkingShown,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -532,7 +532,7 @@ func (m Model) eventInspectorModal() string {
|
||||
fg = t.P.FgStrong
|
||||
}
|
||||
row := marker + mbg(t, padRaw(e.Time, 8)+" ", t.P.Faint) +
|
||||
lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).Render(padRaw("["+cat+"]", 11)) + " " +
|
||||
lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).Render(padRaw("["+cat+"]", 11)) + mbg(t, " ", t.P.Fg) +
|
||||
lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(e.Type, 18)) +
|
||||
mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
|
||||
b.WriteString(row + "\n")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type prefs struct {
|
||||
StatusbarHidden []string `json:"statusbarHidden"`
|
||||
InlineActionsHidden bool `json:"inlineActionsHidden"`
|
||||
ThinkingShown bool `json:"thinkingShown"`
|
||||
}
|
||||
|
||||
// statusSegment is one toggleable status-bar segment. The always-on segments (correx label,
|
||||
@@ -97,3 +98,10 @@ func saveInlineActionsHidden(hidden bool) {
|
||||
p.InlineActionsHidden = hidden
|
||||
savePrefs(p)
|
||||
}
|
||||
|
||||
// saveThinkingShown persists the reasoning-blocks toggle, preserving other prefs fields.
|
||||
func saveThinkingShown(shown bool) {
|
||||
p := loadPrefs()
|
||||
p.ThinkingShown = shown
|
||||
savePrefs(p)
|
||||
}
|
||||
|
||||
@@ -352,6 +352,17 @@ func matrixCases() []matrixCase {
|
||||
want: []string{"corx-ws"},
|
||||
},
|
||||
|
||||
// --- session rename (status bar shows intent-derived title) ---
|
||||
{
|
||||
name: "session.renamed",
|
||||
kinds: []string{protocol.TypeSessionRenamed},
|
||||
surface: surfaceStatusBar,
|
||||
build: func() protocol.ServerMessage {
|
||||
return msg(protocol.TypeSessionRenamed, func(m *protocol.ServerMessage) { m.Name = "Add Auth Layer" })
|
||||
},
|
||||
want: []string{"Add Auth Layer"},
|
||||
},
|
||||
|
||||
// --- approval gate (docked band card) ---
|
||||
{
|
||||
name: "approval.required",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type SessionSummary struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
Intent string `json:"intent"`
|
||||
WorkflowID string `json:"workflowId"`
|
||||
StageCount int `json:"stageCount"`
|
||||
@@ -222,7 +223,12 @@ func (m Model) sessionListRow(i int) string {
|
||||
statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.BgPanel).Bold(true).
|
||||
Render(padRaw(statusLabel(s.Status), 9))
|
||||
|
||||
stage := s.WorkflowID
|
||||
// Prefer the human session name (SessionNamedEvent, intent-derived) over the raw
|
||||
// workflow id — it's what the operator recognizes a session by.
|
||||
stage := s.Name
|
||||
if stage == "" {
|
||||
stage = s.WorkflowID
|
||||
}
|
||||
if s.StageCount > 0 {
|
||||
stage += " ·" + itoa(s.StageCount) + "stg"
|
||||
}
|
||||
|
||||
@@ -1160,6 +1160,7 @@ func paletteCommands() []paletteCmd {
|
||||
{"rail", "", "idle rail", "show / hide the idle status + keys rail"},
|
||||
{"panel", "d", "right panel", "in-session: events → changes → off"},
|
||||
{"actions", "", "inline actions", "show / hide tool & action rows in output"},
|
||||
{"thinking", "", "thinking blocks", "show / hide model reasoning in output"},
|
||||
{"help", "?", "help", "keybinding cheat-sheet"},
|
||||
{"mode", "s", "toggle mode", "switch chat / steering"},
|
||||
{"cancel", "c", "cancel session", "stop the selected session"},
|
||||
@@ -1217,6 +1218,9 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
||||
m.cycleRightPanel()
|
||||
case "actions":
|
||||
m.toggleInlineActions()
|
||||
case "thinking":
|
||||
m.thinkingShown = !m.thinkingShown
|
||||
saveThinkingShown(m.thinkingShown)
|
||||
case "help":
|
||||
m.overlay = OverlayHelp
|
||||
m.modalScroll = 0
|
||||
|
||||
@@ -573,7 +573,7 @@ func (m Model) launcherInput(w int) string {
|
||||
box := strings.Join(boxLines, "\n")
|
||||
|
||||
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
|
||||
leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
|
||||
leftSub := t.span(" ", t.P.Faint) + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
|
||||
hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint)
|
||||
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
|
||||
}
|
||||
@@ -823,6 +823,23 @@ func (m Model) buildTranscriptRows(w int) ([]string, []int) {
|
||||
}
|
||||
case "tool":
|
||||
rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim))
|
||||
case "thinking":
|
||||
// Collapsed by default to a single muted line so the reasoning trace doesn't bury
|
||||
// the answer; the palette "thinking" toggle reveals the full dimmed block.
|
||||
brain := lipgloss.NewStyle().Foreground(t.P.Faint).Background(t.P.Bg).Render("✼")
|
||||
if !m.thinkingShown {
|
||||
n := strings.Count(strings.TrimRight(e.Content, "\n"), "\n") + 1
|
||||
rows = append(rows, brain+t.span(" thinking ("+plural(n, "line")+") — palette: thinking", t.P.Faint))
|
||||
break
|
||||
}
|
||||
lines := wrap(e.Content, w-2)
|
||||
for i, ln := range lines {
|
||||
if i == 0 {
|
||||
rows = append(rows, brain+t.span(" ", t.P.Bg)+t.span(ln, t.P.Faint))
|
||||
} else {
|
||||
rows = append(rows, t.span(" ", t.P.Bg)+t.span(ln, t.P.Faint))
|
||||
}
|
||||
}
|
||||
case "narration_llm":
|
||||
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
|
||||
lines := wrap(e.Content, w-2)
|
||||
@@ -913,7 +930,7 @@ func (m Model) eventRows(w, h int) []string {
|
||||
catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
|
||||
Render(" " + padRaw(cat, 9) + " ")
|
||||
evRows = append(evRows,
|
||||
t.span(e.Time+" ", t.P.Faint)+catCell+" "+
|
||||
t.span(e.Time+" ", t.P.Faint)+catCell+t.span(" ", t.P.Bg)+
|
||||
t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim))
|
||||
}
|
||||
// Show the latest events that fit under the 2-line header (box inner = h-2).
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
TypeApprovalResolved = "approval.resolved"
|
||||
TypeClarification = "clarification.required"
|
||||
TypeWorkflowProposed = "workflow.proposed"
|
||||
TypeSessionRenamed = "session.renamed"
|
||||
TypeWorkspaceBound = "session.workspace_bound"
|
||||
TypeStageManifest = "stage.tool_manifest"
|
||||
TypeProviderStatus = "provider.status_changed"
|
||||
@@ -70,30 +71,34 @@ const (
|
||||
type ServerMessage struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
SessionID string `json:"sessionId"`
|
||||
WorkflowID string `json:"workflowId"`
|
||||
StageID string `json:"stageId"`
|
||||
ToolName string `json:"toolName"`
|
||||
Role string `json:"role"`
|
||||
TurnID string `json:"turnId"`
|
||||
Reason string `json:"reason"`
|
||||
Summary string `json:"outputSummary"`
|
||||
Response string `json:"responseText"`
|
||||
LastOutput string `json:"lastOutput"` // session_snapshot: last stage output (reopen)
|
||||
LastResponse string `json:"lastResponse"` // session_snapshot: last stage response (reopen)
|
||||
Content string `json:"content"`
|
||||
Diff *string `json:"diff"`
|
||||
SessionID string `json:"sessionId"`
|
||||
WorkflowID string `json:"workflowId"`
|
||||
StageID string `json:"stageId"`
|
||||
ToolName string `json:"toolName"`
|
||||
Role string `json:"role"`
|
||||
TurnID string `json:"turnId"`
|
||||
Reason string `json:"reason"`
|
||||
Summary string `json:"outputSummary"`
|
||||
Response string `json:"responseText"`
|
||||
Reasoning string `json:"reasoning"` // inference.completed: model thinking trace (collapsed view)
|
||||
LastOutput string `json:"lastOutput"` // session_snapshot: last stage output (reopen)
|
||||
LastResponse string `json:"lastResponse"` // session_snapshot: last stage response (reopen)
|
||||
Content string `json:"content"`
|
||||
Diff *string `json:"diff"`
|
||||
// tool.completed: file/dir path(s) the tool read, wrote, or listed.
|
||||
AffectedEntities []string `json:"affectedEntities"`
|
||||
Tier string `json:"tier"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"requestId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Preview *string `json:"preview"`
|
||||
Disposition string `json:"disposition"`
|
||||
Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED
|
||||
WorkspaceRoot string `json:"workspaceRoot"` // session.workspace_bound: the bound cwd
|
||||
// tool.started: pretty "key=value" call arguments (path=…, command="…").
|
||||
Params []string `json:"params"`
|
||||
Tier string `json:"tier"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"requestId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
ProviderID string `json:"providerId"`
|
||||
Preview *string `json:"preview"`
|
||||
Disposition string `json:"disposition"`
|
||||
Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED
|
||||
WorkspaceRoot string `json:"workspaceRoot"` // session.workspace_bound: the bound cwd
|
||||
Name string `json:"name"` // session.renamed: human title derived from intent
|
||||
|
||||
// plan.locked: the compiled phase-2 stage ids in execution order
|
||||
StageIDs []string `json:"stageIds"`
|
||||
@@ -134,6 +139,7 @@ type ServerMessage struct {
|
||||
PendingAppr []ApprovalDto `json:"pendingApprovals"`
|
||||
Tools []ToolRecordDto `json:"tools"`
|
||||
RecentEvents []EventEntryDto `json:"recentEvents"`
|
||||
Transcript []TranscriptRowDto `json:"transcript"`
|
||||
Stages []StageToolDecl `json:"stages"`
|
||||
Workflows []WorkflowDto `json:"workflows"`
|
||||
AssessedIssues []AssessedIssueDto `json:"issues"`
|
||||
@@ -387,6 +393,22 @@ type ToolRecordDto struct {
|
||||
Diff *string `json:"diff"`
|
||||
}
|
||||
|
||||
// TranscriptRowDto is one raw OUTPUT-transcript source row from a session_snapshot; the client
|
||||
// folds these into RouterEntry rows on reopen, applying its own diff/param/action formatting.
|
||||
type TranscriptRowDto struct {
|
||||
Kind string `json:"kind"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolName string `json:"toolName"`
|
||||
Status string `json:"status"`
|
||||
Diff *string `json:"diff"`
|
||||
Params []string `json:"params"`
|
||||
Summary string `json:"summary"`
|
||||
AffectedEntities []string `json:"affectedEntities"`
|
||||
LatencyMs *int64 `json:"latencyMs"`
|
||||
TotalTokens *int `json:"totalTokens"`
|
||||
}
|
||||
|
||||
type WorkflowDto struct {
|
||||
WorkflowID string `json:"workflowId"`
|
||||
Description string `json:"description"`
|
||||
@@ -413,7 +435,7 @@ func (m ServerMessage) IsEventBearing() bool {
|
||||
TypeToolAssessed,
|
||||
TypeApprovalRequired, TypeApprovalResolved, TypeClarification,
|
||||
TypeWorkflowProposed,
|
||||
TypeWorkspaceBound, TypeArtifactCreated,
|
||||
TypeSessionRenamed, TypeWorkspaceBound, TypeArtifactCreated,
|
||||
TypeArtifactValid, TypePlanLocked,
|
||||
TypeRouterNarration, TypeReviewFindings:
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user