Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/web/src/components/chat/usePendingThreadRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useShallow } from "zustand/react/shallow";
import { type DraftId, useComposerDraftStore } from "../../composerDraftStore";
import { useStableCallback } from "../../hooks/useStableCallback";
import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue";
import { buildMessageContext } from "../../lib/composerContextRecords";
import {
buildPendingUserInputAnswers,
setPendingUserInputCustomAnswer,
Expand Down Expand Up @@ -306,6 +307,8 @@ export function usePendingThreadRequests(input: {
if (userInputResponsesInFlight.current.has(responseKey)) return;
const attachmentsByQuestionId = new Map<string, UserInputAttachments[string]>();
const providerAnswers = { ...answers };
const threadDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget);
const threadDraftRecords = threadDraft ? (buildMessageContext(threadDraft)?.records ?? []) : [];
for (const question of pendingInput.questions) {
const target = questionAttachmentDraftId(
environmentId,
Expand All @@ -315,10 +318,10 @@ export function usePendingThreadRequests(input: {
);
if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
const draft = useComposerDraftStore.getState().getComposerDraft(target);
providerAnswers[question.id] = projectPendingUserInputAnswer(
answers[question.id],
draft?.threadContexts ?? [],
);
providerAnswers[question.id] = projectPendingUserInputAnswer(answers[question.id], [
...threadDraftRecords,
...(draft?.threadContexts ?? []),
]);
const attachments = draft ? [...draft.images, ...draft.files] : [];
if (attachments.length === 0) continue;
const uploaded = getUploadedAttachments({ environmentId, images: attachments });
Expand Down
61 changes: 60 additions & 1 deletion apps/web/src/pendingUserInput.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { formatInlineContextReference } from "./lib/composerContextReferences";
import { threadContextReference } from "./lib/composerContextRecords";
import {
imageContextReference,
reviewCommentContextRecord,
reviewCommentContextReference,
threadContextReference,
} from "./lib/composerContextRecords";
import { threadContextRecord } from "@t3tools/shared/composerContextReferences";

import {
Expand All @@ -27,6 +32,60 @@ describe("projectPendingUserInputAnswer", () => {
);
});

// regression: #28022 review comment chips in question answers reached the agent as bare links
it("sends review comment chips with the comment text and diff", () => {
const comment = {
id: "review-comment-answer",
sectionId: "working-tree",
sectionTitle: "Working tree",
filePath: "docs/plan.md",
startIndex: 15,
endIndex: 15,
rangeLabel: "L16",
text: "Rename this step to match the migration",
diff: "+- Step 3: backfill legacy rows",
};
const reviewRecord = reviewCommentContextRecord(comment);
const answer = `See ${formatInlineContextReference(reviewCommentContextReference(comment))}`;
const projected = projectPendingUserInputAnswer(answer, [reviewRecord]);
expect(projected).toContain(comment.text);
expect(projected).toContain(comment.diff);
expect(projected).not.toContain("t3-context://");
});

it("sends image chips as their label and leaves image-only answers unchanged", () => {
const comment = {
id: "review-comment-image",
sectionId: "working-tree",
sectionTitle: "Working tree",
filePath: "docs/plan.md",
startIndex: 15,
endIndex: 15,
rangeLabel: "L16",
text: "Match this screenshot",
diff: "+- Step 3",
};
const image = {
type: "image" as const,
id: "image-answer",
name: "screen.png",
mimeType: "image/png",
sizeBytes: 4,
previewUrl: "blob:screen",
file: new File(["png"], "screen.png", { type: "image/png" }),
};
const imageChip = formatInlineContextReference(imageContextReference(image));
const records = [reviewCommentContextRecord(comment)];
const projected = projectPendingUserInputAnswer(
`${imageChip} vs ${formatInlineContextReference(reviewCommentContextReference(comment))}`,
records,
);
expect(projected).toContain("screen.png vs ");
expect(projected).not.toContain('unavailable="true"');
expect(projected).not.toContain('kind="image"');
expect(projectPendingUserInputAnswer(`Like ${imageChip}`, records)).toBe(`Like ${imageChip}`);
});

// regression: #18789 answers to pending questions lost exact text and option values
it("preserves option values and exact answer text", () => {
const options = [" first\t", "second"];
Expand Down
38 changes: 30 additions & 8 deletions apps/web/src/pendingUserInput.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import type {
ComposerContextRecord,
ThreadContextRecord,
UserInputQuestion,
} from "@t3tools/contracts";
import {
projectComposerContextForProvider,
replaceComposerContextReferences,
} from "@t3tools/shared/composerContextReferences";
import { formatThreadLink } from "@t3tools/shared/threadLinks";
import {
buildPendingUserInputAnswers,
Expand All @@ -11,16 +18,31 @@ import {

import { collectInlineContextIds } from "./lib/composerContextReferences";

const isAttachmentReference = (reference: { kind: string }) =>
reference.kind === "image" || reference.kind === "file";

// Thread chips stay readable links. Other chips carry their payload as a normal send does, except
// attachments: the server appends their paths, so they keep only their label.
export function projectPendingUserInputAnswer(
answer: unknown,
threadContexts: ReadonlyArray<ThreadContextRecord>,
records: ReadonlyArray<ComposerContextRecord>,
) {
if (typeof answer !== "string" || threadContexts.length === 0) return answer;
const recordsById = new Map(threadContexts.map((record) => [record.contextId, record]));
return replaceComposerContextReferences(answer, (reference) => {
const record = reference.kind === "thread" ? recordsById.get(reference.contextId) : undefined;
return record ? formatThreadLink(record, reference.label) : reference.source;
if (typeof answer !== "string" || records.length === 0) return answer;
const recordsById = new Map(records.map((record) => [record.contextId, record]));
let expandsPayload = false;
const text = replaceComposerContextReferences(answer, (reference) => {
const record = recordsById.get(reference.contextId);
if (record?.kind === "thread" && "threadId" in record) {
return formatThreadLink(record, reference.label);
}
expandsPayload ||= record !== undefined && !isAttachmentReference(reference);
return reference.source;
});
if (!expandsPayload) return text;
const withoutAttachments = replaceComposerContextReferences(text, (reference) =>
isAttachmentReference(reference) ? reference.label : reference.source,
);
return projectComposerContextForProvider({ text: withoutAttachments, records });
}

// Question drafts retain deleted chip records for undo; only transfer referenced records.
Expand Down
3 changes: 2 additions & 1 deletion packages/client-runtime/src/work-log/userInput.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type UserInputAttachmentAnswerPayload } from "@t3tools/contracts";
import { stripComposerContextEnvelope } from "@t3tools/shared/composerContextReferences";

function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
Expand All @@ -7,7 +8,7 @@ function record(value: unknown): Record<string, unknown> | undefined {
}

export function getQuestionAnswerText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "string") return stripComposerContextEnvelope(value);
if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", ");
const nested = record(value);
return nested ? getQuestionAnswerText(nested.answers) : "";
Expand Down
18 changes: 18 additions & 0 deletions packages/shared/src/composerContextReferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
projectComposerContextForProvider,
restoreAttachmentOnlyBootstrapPrompt,
stripAttachmentOnlyBootstrapPrompt,
stripComposerContextEnvelope,
threadContextRecord,
threadContextsOutsideEnvironment,
toComposerContextId,
Expand Down Expand Up @@ -321,6 +322,23 @@ describe("provider projection", () => {
expect(projected).not.toContain("another payload");
expect(projected).not.toContain("boom");
});

it("strips the generated envelope for display and leaves other text unchanged", () => {
const projected = projectComposerContextForProvider({
text: "See [log](t3-context://v1/terminal/ctx_t)",
records: [terminal],
});
const body = "See [Terminal: log; ref=ctx_t]";
expect(stripComposerContextEnvelope(projected)).toBe(body);
const attachments = '\n\nAttached image "a.png": "/p"';
expect(stripComposerContextEnvelope(`${projected}${attachments}`)).toBe(
`${body}${attachments}`,
);
const typed =
'Quote\n\n<t3_context version="1">\n<context kind="terminal" id="ctx_t"/>\n</t3_context>';
expect(stripComposerContextEnvelope(typed)).toBe(typed);
expect(stripComposerContextEnvelope("Plain answer")).toBe("Plain answer");
});
});

describe("threadContextsOutsideEnvironment", () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/shared/src/composerContextReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,37 @@ export function projectComposerContextForProvider(input: {
return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n</${CONTEXT_ENVELOPE_TAG}>`;
}

/** Display text for a provider-projected message: the text without its generated envelope. */
export function stripComposerContextEnvelope(text: string): string {
const opener = `\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n`;
const closer = `\n</${CONTEXT_ENVELOPE_TAG}>`;
const start = text.lastIndexOf(opener);
if (start === -1) return text;
// Payloads are escaped, so the first closer and every `<context` line are generated.
const end = text.indexOf(closer, start + opener.length);
if (end === -1) return text;
const rest = text.slice(end + closer.length);
if (rest !== "" && !rest.startsWith("\n\n")) return text;
const body = text.slice(0, start);
const markers = new Set<string>();
for (let index = body.indexOf("ref="); index !== -1; index = body.indexOf("ref=", index + 4)) {
// Context ids are at most 128 characters, which bounds each lookahead.
const candidate = body.slice(index + 4, index + 133);
const close = candidate.indexOf("]");
if (close > 0) markers.add(candidate.slice(0, close));
}
const entryOpen = `<${CONTEXT_ENTRY_TAG} kind="`;
let entries = 0;
for (const line of text.slice(start + opener.length, end).split("\n")) {
if (!line.startsWith(entryOpen)) continue;
const idStart = line.indexOf('" id="', entryOpen.length);
const idEnd = idStart === -1 ? -1 : line.indexOf('"', idStart + 6);
if (idEnd === -1 || !markers.has(line.slice(idStart + 6, idEnd))) return text;
entries += 1;
}
return entries > 0 ? body + rest : text;
}

// An agent reads threads on its own machine and on those it links to, even when a draft
// moves between machines.
export function threadContextsOutsideEnvironment(input: {
Expand Down
Loading