mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(channel): shared provisional-narration tracker as the single supersede authority
This commit is contained in:
parent
547adb30ee
commit
8a7a04ee1f
@ -0,0 +1,180 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.agent.ContentKind;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Single authority for the "provisional narration" lifecycle shared by every
|
||||
* user-facing surface.
|
||||
*
|
||||
* <p>A {@link ContentKind#PRE_TOOL_NARRATION} span is written before any tool
|
||||
* observation of its turn, in a completion that goes on to call tools — it may
|
||||
* be process narration or a fully fabricated rehearsal of the result. The
|
||||
* policy, identical everywhere:
|
||||
*
|
||||
* <ul>
|
||||
* <li>stage it instead of publishing (it may stay visible transiently, e.g.
|
||||
* in a live progress bubble or a running SSE segment);</li>
|
||||
* <li>the turn's next content span (grounded narration or final answer)
|
||||
* supersedes it — it must not become permanent output;</li>
|
||||
* <li>if the turn ends with no later content at all, it is committed: with
|
||||
* no replacement it is everything the user gets.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Grounded narrations and final answers never stage — they publish
|
||||
* directly. Producers that predate the kind tag emit {@code null} kinds; the
|
||||
* streaming API accepts a caller-supplied structural fallback signal for that
|
||||
* case, and the segment-marking API leaves untagged timelines to the legacy
|
||||
* structural detector.
|
||||
*
|
||||
* <p>Instances are single-turn and not thread-safe — create one per stream
|
||||
* consumption, confine to the consuming thread (matches how channel adapters
|
||||
* drain a turn's {@code Flux} today).
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ProvisionalContentTracker {
|
||||
|
||||
/** Marker value stored in {@code supersededReason} — same wire value the
|
||||
* legacy structural detector writes, so the UI needs no new vocabulary. */
|
||||
public static final String REASON_PRE_TOOL_CONTENT_REPLACED =
|
||||
"pre_tool_content_replaced_by_post_tool_answer";
|
||||
|
||||
private static final String METRIC_SUPERSEDED = "mateclaw.narration.superseded";
|
||||
|
||||
/** Where the supersede happened — metric tag, one value per surface. */
|
||||
private final String surface;
|
||||
|
||||
private String pendingText;
|
||||
private boolean pendingProvisional;
|
||||
|
||||
public ProvisionalContentTracker(String surface) {
|
||||
this.surface = surface;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a per-round narration. Returns the <em>previous</em> staged
|
||||
* narration if the new arrival makes it publishable, or {@code null} when
|
||||
* there is nothing to publish (no previous, or the previous was
|
||||
* provisional and is now superseded by this later content).
|
||||
*
|
||||
* @param kind producer-assigned kind; {@code null} for
|
||||
* pre-tag producers
|
||||
* @param observedSinceLast structural fallback used only when {@code kind}
|
||||
* is null: whether a tool observation completed
|
||||
* since the previous narration (the pre-tag
|
||||
* online rule)
|
||||
*/
|
||||
public String stageNarration(String text, ContentKind kind, boolean observedSinceLast) {
|
||||
boolean provisional = kind != null
|
||||
? kind == ContentKind.PRE_TOOL_NARRATION
|
||||
: !observedSinceLast;
|
||||
String previous = pendingText;
|
||||
boolean previousProvisional = pendingProvisional;
|
||||
pendingText = text;
|
||||
pendingProvisional = provisional;
|
||||
if (previous == null) {
|
||||
return null;
|
||||
}
|
||||
if (previousProvisional) {
|
||||
recordSuperseded(previous);
|
||||
return null;
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the staged narration at turn end. Returns the text to publish,
|
||||
* or {@code null} when nothing remains (no staged narration, or it was
|
||||
* provisional and the turn produced final content that replaces it).
|
||||
*
|
||||
* @param hasFinalContent whether the turn produced a final answer — with
|
||||
* one, a provisional narration is superseded; with
|
||||
* none, even a provisional narration commits (no
|
||||
* replacement exists)
|
||||
*/
|
||||
public String settle(boolean hasFinalContent) {
|
||||
String text = pendingText;
|
||||
boolean provisional = pendingProvisional;
|
||||
pendingText = null;
|
||||
pendingProvisional = false;
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
if (provisional && hasFinalContent) {
|
||||
recordSuperseded(text);
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private void recordSuperseded(String text) {
|
||||
log.info("[{}] provisional narration superseded by later content ({} chars dropped from permanent output)",
|
||||
surface, text.length());
|
||||
Metrics.counter(METRIC_SUPERSEDED, "surface", surface).increment();
|
||||
}
|
||||
|
||||
// ==================== Persisted-timeline marking ====================
|
||||
|
||||
/**
|
||||
* Whether the persisted segments timeline carries producer-assigned kind
|
||||
* tags — i.e. whether {@link #markSuperseded(List, String)} is applicable
|
||||
* or the caller should fall back to structural detection.
|
||||
*/
|
||||
public static boolean hasKindTags(List<Map<String, Object>> segments) {
|
||||
if (segments == null) {
|
||||
return false;
|
||||
}
|
||||
for (Map<String, Object> seg : segments) {
|
||||
if ("content".equals(seg.get("type")) && seg.get("kind") != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kind-driven counterpart of the structural supersede scan: a
|
||||
* {@code pre_tool_narration} content segment is marked superseded by the
|
||||
* first content segment that follows it; grounded narrations and final
|
||||
* answers are never marked. Mutates segment maps in place with the same
|
||||
* three keys the structural detector writes ({@code superseded},
|
||||
* {@code supersededBySegmentId}, {@code supersededReason}).
|
||||
*/
|
||||
public static void markSuperseded(List<Map<String, Object>> segments, String surface) {
|
||||
if (segments == null || segments.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String preToolWire = ContentKind.PRE_TOOL_NARRATION.wireName();
|
||||
for (int i = 0; i < segments.size(); i++) {
|
||||
Map<String, Object> seg = segments.get(i);
|
||||
if (!"content".equals(seg.get("type"))
|
||||
|| !preToolWire.equals(seg.get("kind"))
|
||||
|| Boolean.TRUE.equals(seg.get("superseded"))) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> replacement = nextContent(segments, i + 1);
|
||||
if (replacement == null) {
|
||||
continue; // turn produced no later content — the narration stands
|
||||
}
|
||||
seg.put("superseded", true);
|
||||
seg.put("supersededBySegmentId", String.valueOf(replacement.getOrDefault("id", "")));
|
||||
seg.put("supersededReason", REASON_PRE_TOOL_CONTENT_REPLACED);
|
||||
log.info("[{}] provisional narration segment {} superseded by segment {}",
|
||||
surface, seg.get("id"), replacement.get("id"));
|
||||
Metrics.counter(METRIC_SUPERSEDED, "surface", surface).increment();
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> nextContent(List<Map<String, Object>> segments, int from) {
|
||||
for (int i = from; i < segments.size(); i++) {
|
||||
if ("content".equals(segments.get(i).get("type"))) {
|
||||
return segments.get(i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.channel.ProvisionalContentTracker;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -472,7 +473,14 @@ public final class AgentStreamAccumulator {
|
||||
public synchronized String toMetadataJson() {
|
||||
finalizeToolCalls();
|
||||
finalizeRunningSegments("thinking", "content", "tool_call");
|
||||
SegmentSupersedeDetector.markSuperseded(segments);
|
||||
// Producer-tagged timelines use the kind-driven authority; untagged
|
||||
// ones (pre-tag producers, replayed legacy turns) keep the structural
|
||||
// scan as fallback.
|
||||
if (ProvisionalContentTracker.hasKindTags(segments)) {
|
||||
ProvisionalContentTracker.markSuperseded(segments, "web");
|
||||
} else {
|
||||
SegmentSupersedeDetector.markSuperseded(segments);
|
||||
}
|
||||
try {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
if (!toolCalls.isEmpty()) {
|
||||
|
||||
@ -7,6 +7,7 @@ import vip.mate.agent.AgentService.StreamDelta;
|
||||
import vip.mate.channel.AbstractChannelAdapter;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.ProvisionalContentTracker;
|
||||
import vip.mate.channel.StreamingChannelAdapter;
|
||||
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||
import vip.mate.channel.ExponentialBackoff;
|
||||
@ -1416,7 +1417,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
}
|
||||
})
|
||||
.blockLast(Duration.ofMinutes(10));
|
||||
outcome = new StreamOutcome(null, false, false);
|
||||
outcome = new StreamOutcome(null, false);
|
||||
} else {
|
||||
outcome = consumeWithProgress(stream, message, replyTarget, ctx, contentAccumulator);
|
||||
}
|
||||
@ -1432,10 +1433,11 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
// is likewise dropped once a grounded answer exists: whatever it says
|
||||
// — process narration or a predicted result — it was written before
|
||||
// this turn's observations, and IM bubbles cannot be retracted later.
|
||||
String pendingNarration = outcome.pendingNarration();
|
||||
String pendingNarration = outcome.tracker() != null
|
||||
? outcome.tracker().settle(!finalContent.isBlank())
|
||||
: null;
|
||||
if (!finalContent.isBlank()) {
|
||||
if (pendingNarration != null && !outcome.pendingNarrationPreTool()
|
||||
&& !sameOutboundText(pendingNarration, finalContent)) {
|
||||
if (pendingNarration != null && !sameOutboundText(pendingNarration, finalContent)) {
|
||||
publishNarrationBubble(replyTarget, pendingNarration);
|
||||
}
|
||||
renderAndSend(replyTarget, finalContent);
|
||||
@ -1459,39 +1461,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
* which only {@link #processStream} can act on (it needs the final answer
|
||||
* first).
|
||||
*
|
||||
* @param pendingNarration the last per-stage narration, still unpublished
|
||||
* @param pendingNarrationPreTool the pending narration was written before any
|
||||
* tool observation and tools ran after it — not
|
||||
* grounded, so it must not become a permanent
|
||||
* bubble once a grounded answer exists
|
||||
* @param approvalPending a tool call is parked on human approval
|
||||
* @param tracker the narration lifecycle tracker holding the last
|
||||
* per-stage narration, still unresolved — settled by
|
||||
* {@code processStream} once the final answer is
|
||||
* known; {@code null} on the degraded path where
|
||||
* narration is never staged
|
||||
* @param approvalPending a tool call is parked on human approval
|
||||
*/
|
||||
private record StreamOutcome(String pendingNarration, boolean pendingNarrationPreTool,
|
||||
boolean approvalPending) {}
|
||||
|
||||
/**
|
||||
* A per-stage narration held back from publication, with the structural
|
||||
* facts needed to decide later whether it may become a permanent bubble.
|
||||
*
|
||||
* @param text the narration text
|
||||
* @param followsToolResult a tool observation completed between the previous
|
||||
* narration (or turn start) and this one — the text
|
||||
* was written with real output in hand
|
||||
* @param toolMark tool-observation count at staging time; a higher
|
||||
* count later means tools ran after this narration
|
||||
*/
|
||||
private record StagedNarration(String text, boolean followsToolResult, int toolMark) {
|
||||
|
||||
/**
|
||||
* Whether this narration is superseded by whatever content follows it:
|
||||
* it was not grounded in an observation, and tool calls ran after it —
|
||||
* the same structural rule the Web segments timeline applies, evaluated
|
||||
* online. The caller only asks once follow-up content exists.
|
||||
*/
|
||||
boolean supersededAt(int toolResultsNow) {
|
||||
return !followsToolResult && toolResultsNow > toolMark;
|
||||
}
|
||||
}
|
||||
private record StreamOutcome(ProvisionalContentTracker tracker, boolean approvalPending) {}
|
||||
|
||||
/** Compare two outbound texts the way the user sees them (post-filter, trimmed). */
|
||||
private boolean sameOutboundText(String a, String b) {
|
||||
@ -1554,7 +1531,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
// as the new progress bubble, so chat chronology stays intact and
|
||||
// the final answer always lands in the newest bubble.
|
||||
AtomicReference<WeComReplyContext> liveCtx = new AtomicReference<>(initialCtx);
|
||||
AtomicReference<StagedNarration> pendingNarration = new AtomicReference<>();
|
||||
ProvisionalContentTracker tracker = new ProvisionalContentTracker("wecom");
|
||||
AtomicInteger toolResults = new AtomicInteger();
|
||||
final int[] lastNarrationToolMark = {0};
|
||||
final long[] lastFlushAt = {0L};
|
||||
@ -1575,33 +1552,30 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
// a wall of text, and persisted they pollute the next turn's
|
||||
// LLM history with unanswered chain-of-thought.
|
||||
//
|
||||
// Publishing lags one narration behind: the newest one is only
|
||||
// staged (visible live in the bubble, not yet finalized) so a
|
||||
// later decision can still drop it — when the final answer
|
||||
// turns out to be the same text, or when it proves to be a
|
||||
// pre-tool rehearsal. A narration written before any tool
|
||||
// observation, with tool calls running after it, is not
|
||||
// grounded in this turn's results (it may even be a predicted
|
||||
// result table); once grounded content follows, it is dropped
|
||||
// instead of becoming an unretractable permanent bubble.
|
||||
// Narrations written after an observation keep standalone
|
||||
// value (e.g. intermediate results) and publish as before.
|
||||
// Publishing lags one narration behind via the shared
|
||||
// lifecycle tracker: the newest narration is only staged
|
||||
// (visible live in the bubble, not yet finalized) so a later
|
||||
// decision can still drop it — a pre-tool rehearsal must not
|
||||
// become an unretractable permanent bubble once grounded
|
||||
// content follows. The producer-assigned kind decides; for
|
||||
// untagged deltas the tracker falls back to the observation
|
||||
// counter (a tool result completed since the last narration
|
||||
// means this one was written with real output in hand).
|
||||
String narration = delta.content() != null ? delta.content().trim() : "";
|
||||
if (!narration.isEmpty()) {
|
||||
int mark = toolResults.get();
|
||||
StagedNarration staged = new StagedNarration(
|
||||
narration, mark > lastNarrationToolMark[0], mark);
|
||||
boolean observedSinceLast = mark > lastNarrationToolMark[0];
|
||||
lastNarrationToolMark[0] = mark;
|
||||
StagedNarration previous = pendingNarration.getAndSet(staged);
|
||||
progress.onNarration(narration);
|
||||
if (previous != null && !previous.supersededAt(mark)) {
|
||||
String publishable = tracker.stageNarration(narration, delta.kind(), observedSinceLast);
|
||||
if (publishable != null) {
|
||||
WeComReplyContext ctx = liveCtx.get();
|
||||
if (replyContexts.get(replyTarget) == ctx) {
|
||||
liveCtx.set(rollProgressBubble(replyTarget, ctx, previous.text(), progress));
|
||||
liveCtx.set(rollProgressBubble(replyTarget, ctx, publishable, progress));
|
||||
} else {
|
||||
// Bubble already force-finished (180s ceiling) — the
|
||||
// narration still goes out as a plain message.
|
||||
sendMessage(replyTarget, previous.text());
|
||||
sendMessage(replyTarget, publishable);
|
||||
}
|
||||
}
|
||||
flushNow = true;
|
||||
@ -1638,11 +1612,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
||||
}
|
||||
}).blockLast(Duration.ofMinutes(10));
|
||||
|
||||
StagedNarration last = pendingNarration.get();
|
||||
return new StreamOutcome(
|
||||
last != null ? last.text() : null,
|
||||
last != null && last.supersededAt(toolResults.get()),
|
||||
progress.isApprovalPending());
|
||||
return new StreamOutcome(tracker, progress.isApprovalPending());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.ContentKind;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* State machine of the shared provisional-narration lifecycle, plus the
|
||||
* kind-driven persisted-timeline marking. The streaming scenarios mirror the
|
||||
* ones the structural detector's tests pin for the web timeline — the two
|
||||
* paths must agree on every case a producer can actually emit.
|
||||
*/
|
||||
class ProvisionalContentTrackerTest {
|
||||
|
||||
// ==================== streaming state machine ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-tool narration superseded by the next narration — never published")
|
||||
void preToolNarrationSupersededByNextNarration() {
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
|
||||
assertNull(t.stageNarration("先查询会议室数据。", ContentKind.PRE_TOOL_NARRATION, false),
|
||||
"first narration has no predecessor to publish");
|
||||
String publishable = t.stageNarration("第一间空闲,继续。", ContentKind.GROUNDED_NARRATION, true);
|
||||
assertNull(publishable, "provisional predecessor is superseded, not published");
|
||||
|
||||
assertEquals("第一间空闲,继续。", t.settle(true),
|
||||
"the grounded successor itself settles publishable");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grounded narration publishes when the next narration arrives")
|
||||
void groundedNarrationPublishesOnNext() {
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
|
||||
t.stageNarration("时间拿到了,再查会议室:", ContentKind.GROUNDED_NARRATION, true);
|
||||
String publishable = t.stageNarration("会议室也查完了。", ContentKind.GROUNDED_NARRATION, true);
|
||||
assertEquals("时间拿到了,再查会议室:", publishable);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-tool narration superseded by the final answer at settle")
|
||||
void preToolNarrationSupersededByFinalAnswer() {
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
|
||||
t.stageNarration("环境监测结果:温度 29.0°C。", ContentKind.PRE_TOOL_NARRATION, false);
|
||||
assertNull(t.settle(true), "fabricated rehearsal must not survive once a grounded answer exists");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pre-tool narration commits when the turn produced no answer at all")
|
||||
void preToolNarrationCommitsWithoutAnswer() {
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
|
||||
t.stageNarration("我先调用工具查询状态:", ContentKind.PRE_TOOL_NARRATION, false);
|
||||
assertEquals("我先调用工具查询状态:", t.settle(false),
|
||||
"with no replacement content the narration is everything the user gets");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grounded narration always settles publishable")
|
||||
void groundedNarrationSettles() {
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
t.stageNarration("查完了,结果如下。", ContentKind.GROUNDED_NARRATION, true);
|
||||
assertEquals("查完了,结果如下。", t.settle(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("settle clears state — second settle finds nothing")
|
||||
void settleClearsState() {
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
t.stageNarration("x", ContentKind.GROUNDED_NARRATION, true);
|
||||
t.settle(true);
|
||||
assertNull(t.settle(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null kind falls back to the observation-counter rule")
|
||||
void nullKindFallsBackToCounter() {
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
|
||||
// No observation since turn start → provisional (pre-tag online rule).
|
||||
t.stageNarration("我先查一下:", null, false);
|
||||
assertNull(t.settle(true), "untagged pre-tool narration still dropped for a grounded answer");
|
||||
|
||||
// Observation completed since last narration → grounded.
|
||||
ProvisionalContentTracker t2 = new ProvisionalContentTracker("test");
|
||||
t2.stageNarration("拿到结果了。", null, true);
|
||||
assertEquals("拿到结果了。", t2.settle(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("producer kind outranks the counter signal when both are present")
|
||||
void kindOutranksCounter() {
|
||||
// The counter says an observation happened, but the producer knows the
|
||||
// text was emitted before any observation of this turn (the two can
|
||||
// disagree when multiple narrations land between two completions).
|
||||
ProvisionalContentTracker t = new ProvisionalContentTracker("test");
|
||||
t.stageNarration("预演内容", ContentKind.PRE_TOOL_NARRATION, true);
|
||||
assertNull(t.settle(true), "kind is authoritative — counter signal ignored when tagged");
|
||||
}
|
||||
|
||||
// ==================== persisted-timeline marking ====================
|
||||
|
||||
private static Map<String, Object> content(String id, String kind) {
|
||||
Map<String, Object> seg = new LinkedHashMap<>();
|
||||
seg.put("id", id);
|
||||
seg.put("type", "content");
|
||||
seg.put("text", "t-" + id);
|
||||
if (kind != null) {
|
||||
seg.put("kind", kind);
|
||||
}
|
||||
return seg;
|
||||
}
|
||||
|
||||
private static Map<String, Object> tool(String id) {
|
||||
Map<String, Object> seg = new LinkedHashMap<>();
|
||||
seg.put("id", id);
|
||||
seg.put("type", "tool_call");
|
||||
return seg;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hasKindTags only reacts to tagged content segments")
|
||||
void hasKindTagsDetection() {
|
||||
assertFalse(ProvisionalContentTracker.hasKindTags(null));
|
||||
assertFalse(ProvisionalContentTracker.hasKindTags(List.of(content("c0", null), tool("t1"))));
|
||||
assertTrue(ProvisionalContentTracker.hasKindTags(
|
||||
List.of(content("c0", "pre_tool_narration"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pre_tool segment marked superseded by the first later content segment")
|
||||
void marksPreToolSegment() {
|
||||
List<Map<String, Object>> segments = new ArrayList<>(List.of(
|
||||
content("c0", "pre_tool_narration"),
|
||||
tool("t1"),
|
||||
content("c2", "final_answer")));
|
||||
|
||||
ProvisionalContentTracker.markSuperseded(segments, "test");
|
||||
|
||||
assertEquals(true, segments.get(0).get("superseded"));
|
||||
assertEquals("c2", segments.get(0).get("supersededBySegmentId"));
|
||||
assertEquals(ProvisionalContentTracker.REASON_PRE_TOOL_CONTENT_REPLACED,
|
||||
segments.get(0).get("supersededReason"));
|
||||
assertFalse(segments.get(2).containsKey("superseded"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("grounded and final segments never marked; trailing pre_tool without replacement stands")
|
||||
void groundedNeverMarkedAndTrailingPreToolStands() {
|
||||
List<Map<String, Object>> segments = new ArrayList<>(List.of(
|
||||
content("c0", "grounded_narration"),
|
||||
tool("t1"),
|
||||
content("c2", "pre_tool_narration")));
|
||||
|
||||
ProvisionalContentTracker.markSuperseded(segments, "test");
|
||||
|
||||
assertFalse(segments.get(0).containsKey("superseded"),
|
||||
"grounded narration is never replaced");
|
||||
assertFalse(segments.get(2).containsKey("superseded"),
|
||||
"no later content exists — the narration is everything the user gets");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("chained tool segments between narration and answer don't block marking")
|
||||
void chainedToolsBetween() {
|
||||
List<Map<String, Object>> segments = new ArrayList<>(List.of(
|
||||
content("c0", "pre_tool_narration"),
|
||||
tool("t1"),
|
||||
tool("t2"),
|
||||
content("c3", "final_answer")));
|
||||
|
||||
ProvisionalContentTracker.markSuperseded(segments, "test");
|
||||
|
||||
assertEquals(true, segments.get(0).get("superseded"));
|
||||
assertEquals("c3", segments.get(0).get("supersededBySegmentId"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user