fix: preserve inter-session input provenance (thanks @anbecker)

This commit is contained in:
Peter Steinberger
2026-02-13 02:01:53 +01:00
parent 7081dee1af
commit 85409e401b
25 changed files with 415 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
import { Type } from "@sinclair/typebox";
import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js";
import { NonEmptyString, SessionLabelString } from "./primitives.js";
export const AgentEventSchema = Type.Object(
@@ -64,6 +65,17 @@ export const AgentParamsSchema = Type.Object(
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()),
inputProvenance: Type.Optional(
Type.Object(
{
kind: Type.String({ enum: [...INPUT_PROVENANCE_KIND_VALUES] }),
sourceSessionKey: Type.Optional(Type.String()),
sourceChannel: Type.Optional(Type.String()),
sourceTool: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
idempotencyKey: NonEmptyString,
label: Type.Optional(SessionLabelString),
spawnedBy: Type.Optional(Type.String()),

View File

@@ -17,6 +17,7 @@ import {
} from "../../infra/outbound/agent-delivery.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import {
@@ -85,6 +86,7 @@ export const agentHandlers: GatewayRequestHandlers = {
timeout?: number;
label?: string;
spawnedBy?: string;
inputProvenance?: InputProvenance;
};
const cfg = loadConfig();
const idem = request.idempotencyKey;
@@ -97,6 +99,7 @@ export const agentHandlers: GatewayRequestHandlers = {
let resolvedGroupSpace: string | undefined = groupSpaceRaw || undefined;
let spawnedByValue =
typeof request.spawnedBy === "string" ? request.spawnedBy.trim() : undefined;
const inputProvenance = normalizeInputProvenance(request.inputProvenance);
const cached = context.dedupe.get(`agent:${idem}`);
if (cached) {
respond(cached.ok, cached.payload, cached.error, {
@@ -400,6 +403,7 @@ export const agentHandlers: GatewayRequestHandlers = {
runId,
lane: request.lane,
extraSystemPrompt: request.extraSystemPrompt,
inputProvenance,
},
defaultRuntime,
context.deps,

View File

@@ -9,6 +9,7 @@ import {
getFreePort,
installGatewayTestHooks,
startGatewayServer,
testState,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -17,13 +18,15 @@ let server: Awaited<ReturnType<typeof startGatewayServer>>;
let gatewayPort: number;
let prevGatewayPort: string | undefined;
let prevGatewayToken: string | undefined;
const gatewayToken = "test-token";
beforeAll(async () => {
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
prevGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
gatewayPort = await getFreePort();
testState.gatewayAuth = { mode: "token", token: gatewayToken };
process.env.OPENCLAW_GATEWAY_PORT = String(gatewayPort);
process.env.OPENCLAW_GATEWAY_TOKEN = "test-token";
process.env.OPENCLAW_GATEWAY_TOKEN = gatewayToken;
server = await startGatewayServer(gatewayPort);
});
@@ -105,8 +108,14 @@ describe("sessions_send gateway loopback", () => {
expect(details.reply).toBe("pong");
expect(details.sessionKey).toBe("main");
const firstCall = spy.mock.calls[0]?.[0] as { lane?: string } | undefined;
const firstCall = spy.mock.calls[0]?.[0] as
| { lane?: string; inputProvenance?: { kind?: string; sourceTool?: string } }
| undefined;
expect(firstCall?.lane).toBe("nested");
expect(firstCall?.inputProvenance).toMatchObject({
kind: "inter_session",
sourceTool: "sessions_send",
});
});
});

View File

@@ -92,6 +92,27 @@ describe("readFirstUserMessageFromTranscript", () => {
expect(result).toBe("First user question");
});
test("skips inter-session user messages by default", () => {
const sessionId = "test-session-inter-session";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({
message: {
role: "user",
content: "Forwarded by session tool",
provenance: { kind: "inter_session", sourceTool: "sessions_send" },
},
}),
JSON.stringify({
message: { role: "user", content: "Real user message" },
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Real user message");
});
test("returns null when no user messages exist", () => {
const sessionId = "test-session-4";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);

View File

@@ -8,6 +8,7 @@ import {
resolveSessionTranscriptPathInDir,
} from "../config/sessions.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js";
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
import { stripEnvelope } from "./chat-sanitize.js";
@@ -139,6 +140,7 @@ const MAX_LINES_TO_SCAN = 10;
type TranscriptMessage = {
role?: string;
content?: string | Array<{ type: string; text?: string }>;
provenance?: unknown;
};
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
@@ -167,6 +169,7 @@ export function readFirstUserMessageFromTranscript(
storePath: string | undefined,
sessionFile?: string,
agentId?: string,
opts?: { includeInterSession?: boolean },
): string | null {
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
const filePath = candidates.find((p) => fs.existsSync(p));
@@ -193,6 +196,9 @@ export function readFirstUserMessageFromTranscript(
const parsed = JSON.parse(line);
const msg = parsed?.message as TranscriptMessage | undefined;
if (msg?.role === "user") {
if (opts?.includeInterSession !== true && hasInterSessionUserProvenance(msg)) {
continue;
}
const text = extractTextFromContent(msg.content);
if (text) {
return text;