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:
2026-07-11 23:56:52 +04:00
parent 3d5e05c1fb
commit 15248cae8a
55 changed files with 1932 additions and 231 deletions
@@ -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) {
@@ -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)
}
}
@@ -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(