diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 6670505ad3988..8a6176c1140c0 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { Atom } from "effect/reactivity"; +import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + restoreAttachmentOnlyBootstrapPrompt, +} from "@t3tools/shared/composerContextReferences"; import { ChatAttachmentId, ComposerContextId, @@ -112,6 +116,30 @@ describe("resolveQueuedEditPayload", () => { }); describe("beginQueuedRunEdit", () => { + it.each(["", "Ultrathink:\n"])( + "opens attachment-only edits without the placeholder and treats them as untouched (%j)", + (prefix) => { + const threadKey = `env-exit:attachment-only-${prefix.length}`; + const original = { + ...edit([image("kept")]), + originalText: prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + }; + const runs = Atom.make | null>( + [{ id: original.runId, status: "queued" }], + ); + beginQueuedRunEdit(threadKey, original, runs); + const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, original.runId)); + expect(draft.text).toBe(prefix.trimEnd()); + expect( + restoreAttachmentOnlyBootstrapPrompt(draft.text, original.existingAttachments.length > 0), + ).toBe(original.originalText); + expect(getQueuedRunEdit(threadKey)).toEqual({ ...original, originalText: prefix.trimEnd() }); + appAtomRegistry.set(runs, [{ id: original.runId, status: "running" }]); + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined(); + }, + ); + it("silently drops an untouched edit when its run starts", () => { const threadKey = "env-exit:thread-untouched"; const runId = RunId.make("run-1"); diff --git a/apps/mobile/src/state/queued-run-edit.ts b/apps/mobile/src/state/queued-run-edit.ts index 08f893d4770a2..45dab37d0528a 100644 --- a/apps/mobile/src/state/queued-run-edit.ts +++ b/apps/mobile/src/state/queued-run-edit.ts @@ -6,6 +6,7 @@ import type { UploadChatAttachment, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; +import { stripAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { makeQueuedRunEditStore, resolveQueuedEditExit, @@ -118,10 +119,15 @@ export function beginQueuedRunEdit( // The composer moves off the thread draft, so a paste or pick still running for it is dropped. abortComposerDraftOperations(threadKey); const draftKey = queuedEditDraftKey(threadKey, edit.runId); + const originalText = stripAttachmentOnlyBootstrapPrompt(edit.originalText); clearComposerDraft(draftKey); - setComposerDraftText(draftKey, edit.originalText); + setComposerDraftText(draftKey, originalText); setComposerDraftContext(draftKey, edit.context); - queuedRunEdits.begin(threadKey, edit, { runs, onRunLeftQueue: exitQueuedRunEdit }); + queuedRunEdits.begin( + threadKey, + { ...edit, originalText }, + { runs, onRunLeftQueue: exitQueuedRunEdit }, + ); } export function removeQueuedRunEditAttachment(threadKey: string, attachmentId: string): void { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 4c14fb56254cf..9056bc20937fa 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -34,6 +34,7 @@ import { } from "@t3tools/client-runtime/state/threads"; import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { restoreAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { composerContextSendBlockReason, reidentifyComposerContext } from "../lib/composerContext"; import { uuidv4 } from "../lib/uuid"; @@ -403,7 +404,11 @@ export function useThreadComposerState() { if (edit === null) return; const draft = getComposerDraftSnapshot(queuedEditDraftKey(threadKey, edit.runId)); const text = draft.text.trim(); - if (text.length === 0) { + if ( + text.length === 0 && + edit.existingAttachments.length === 0 && + draft.attachments.length === 0 + ) { Dialog.alert("Add a message", "A queued message cannot be left empty."); return; } @@ -457,7 +462,7 @@ export function useThreadComposerState() { input: { threadId: thread.id, runId: edit.runId, - text, + text: restoreAttachmentOnlyBootstrapPrompt(text, payload.attachments.length > 0), edit: { messageId: edit.messageId, attachments: payload.attachments, diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 399943c7b1b92..5d816133c89c7 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -18,6 +18,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -141,6 +142,119 @@ const message = ( }, }); +it.effect.each([true, false])( + "ThreadSearch excludes bootstrap text before the hit and candidate limits (indexed: %s)", + (indexed) => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:attachments"); + const threadId = ThreadId.make("thread:attachments"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(threadId, project), + message(threadId, "ordinary", "user", "Review the attached files in context", { + minute: 1, + }), + message(threadId, "attachment", "user", ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, { minute: 2 }), + message( + threadId, + "ultrathink", + "user", + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + { minute: 3 }, + ), + ], + projections.apply, + { discard: true }, + ); + if (!indexed) { + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_update`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_delete`; + yield* sql`DROP TABLE orchestration_v2_message_search`; + } + assert.deepEqual((yield* search.search({ query: "conversation context" })).matches, []); + assert.deepEqual((yield* search.search({ query: "ultrathink" })).matches, []); + assert.deepEqual( + (yield* search.search({ query: "attached files" })).matches.map((match) => [ + match.messageId, + match.snippet, + ]), + [[MessageId.make("ordinary"), "Review the attached files in context"]], + ); + for (const text of [ + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + `uLtRaThInK:\r\n\t\u00a0${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ]) { + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${indexed ? 10_000 : 2000}) + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + SELECT 'attachment-limit:' || i, ${threadId}, 'user', 0, + ${DateTime.formatIso(at(5))}, ${DateTime.formatIso(at(5))}, json_object('text', ${text}) + FROM n + `; + assert.deepEqual( + (yield* search.search({ query: "context" })).matches.map((match) => match.messageId), + [MessageId.make("ordinary")], + ); + yield* sql` + DELETE FROM orchestration_v2_projection_messages WHERE message_id LIKE 'attachment-limit:%' + `; + } + }).pipe(Effect.provide(layerTest)), +); + +it.effect("ThreadSearch excludes legacy placeholders still in the index", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const sql = yield* SqlClient.SqlClient; + const project = ProjectId.make("project:legacy-attachments"); + const threadId = ThreadId.make("thread:legacy-attachments"); + yield* createProject(project); + // Simulate the pre-upgrade trigger, which indexed attachment placeholders. + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + VALUES (new.rowid, json_extract(new.payload_json, '$.text')); + END + `; + yield* Effect.forEach( + [ + thread(threadId, project), + message(threadId, "ordinary", "user", "Review the attached files in context"), + message(threadId, "attachment", "user", ATTACHMENT_ONLY_BOOTSTRAP_PROMPT), + message( + threadId, + "ultrathink", + "user", + `uLtRaThInK:\r\n\t\u00a0${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ), + ], + projections.apply, + { discard: true }, + ); + assert.lengthOf( + yield* sql`SELECT rowid FROM orchestration_v2_message_search WHERE orchestration_v2_message_search MATCH '"context"'`, + 3, + ); + assert.deepEqual((yield* search.search({ query: "conversation context" })).matches, []); + assert.deepEqual((yield* search.search({ query: "ultrathink" })).matches, []); + assert.deepEqual( + (yield* search.search({ query: "context" })).matches.map((match) => match.messageId), + [MessageId.make("ordinary")], + ); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ThreadSearch", (it) => { it.effect("reads the newest hits without sorting all of them", () => Effect.gen(function* () { @@ -153,6 +267,15 @@ it.layer(layerTest)("ThreadSearch", (it) => { plan.some((row) => row.detail.includes("TEMP B-TREE")), plan.map((row) => row.detail).join("\n"), ); + if (match !== null) { + assert.lengthOf( + plan.filter((row) => + row.detail.startsWith("SEARCH messages USING INTEGER PRIMARY KEY"), + ), + 1, + plan.map((row) => row.detail).join("\n"), + ); + } } }), ); diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 37a1b9e726f9b..62c7f4eed45ec 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -33,7 +33,7 @@ import { type ThreadSearchSubject, } from "@t3tools/shared/paletteSearch"; import { threadSearchPullRequests } from "@t3tools/shared/threadPullRequests"; -import { indexedMessageCondition } from "./messageSearchIndex.ts"; +import { indexedMessageCondition, nonPlaceholderMessageCondition } from "./messageSearchIndex.ts"; import { loadOlderSettledThreadsPage } from "./ShellStream.ts"; // When the user last wrote in the thread aliased `threads`, as the clients @@ -209,11 +209,12 @@ export const messageHitsQuery = ( INNER JOIN eligible ON eligible.thread_id = messages.thread_id WHERE orchestration_v2_message_search MATCH ${match} AND orchestration_v2_message_search.rowid >= coalesce(( - SELECT rowid FROM orchestration_v2_message_search + SELECT orchestration_v2_message_search.rowid FROM orchestration_v2_message_search WHERE orchestration_v2_message_search MATCH ${match} - ORDER BY rowid DESC + ORDER BY orchestration_v2_message_search.rowid DESC LIMIT 1 OFFSET ${MESSAGE_CANDIDATE_LIMIT - 1} ), 0) + AND ${nonPlaceholderMessageCondition(sql)} ` } -- Answers to an agent's question show inside its question card, where diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8759917344060..8300d50acb021 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -1,6 +1,8 @@ import { assert, describe, it, vi } from "@effect/vitest"; import { CommandId, + ChatAttachmentId, + type ChatAttachment, EventId, MessageId, ProjectId, @@ -14,6 +16,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectStore from "./ProjectStore.ts"; @@ -134,6 +137,7 @@ function dispatchUserMessage(input: { readonly command: string; readonly threadId: ThreadId; readonly text: string; + readonly attachments?: ReadonlyArray; }) { return Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; @@ -143,7 +147,7 @@ function dispatchUserMessage(input: { threadId: input.threadId, messageId: MessageId.make(`${input.command}:message`), text: input.text, - attachments: [], + attachments: input.attachments ?? [], modelSelection, dispatchMode: { type: "defer_start" }, createdBy: "user", @@ -270,6 +274,44 @@ describe("formatThreadTitleContext", () => { }); describe("ThreadTitleRegenerationService", () => { + it.effect( + "titles an image-only first message without sending bootstrap text to the title model", + () => + Effect.gen(function* () { + for (const prefix of ["", "Ultrathink:\n"]) { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const service = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const threadId = yield* createThread({ + command: "image:create", + thread: "image:thread", + }); + const text = prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT; + const attachments = [ + { + type: "image" as const, + id: ChatAttachmentId.make("diagram"), + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ]; + yield* dispatchUserMessage({ command: "image:message", threadId, text, attachments }); + const requestId = yield* armRegeneration({ command: "image:title", threadId }); + const messageId = MessageId.make("image:message:message"); + yield* service.execute({ threadId, requestId, kind: { type: "initial", messageId } }); + const call = harness.generateThreadTitle.mock.calls[0]?.[0]; + assert.equal(call?.message, ""); + assert.deepEqual(call?.attachments, attachments); + const projection = yield* threads.getThreadProjection(threadId); + assert.equal(projection.messages[0]?.text, text); + assert.equal(projection.thread.title, "Generated title"); + }).pipe(Effect.provide(harness.layer)); + } + }), + ); + it.effect("arms and clears the regeneration marker through metadata commands", () => Effect.gen(function* () { const harness = makeHarness(); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index b73f6a2ab061a..601b783675d0e 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -1,4 +1,5 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { CommandId, type ChatAttachment, @@ -138,7 +139,10 @@ const make = Effect.gen(function* () { context = message === undefined ? { message: "", attachments: [] } - : { message: message.text, attachments: message.attachments }; + : { + message: isAttachmentOnlyBootstrapPrompt(message.text) ? "" : message.text, + attachments: message.attachments, + }; } else { context = formatThreadTitleContext( projection.messages.filter((message) => !message.streaming), diff --git a/apps/server/src/orchestration-v2/messageSearchIndex.ts b/apps/server/src/orchestration-v2/messageSearchIndex.ts index 318a92e9efd5c..dbd3545e5906b 100644 --- a/apps/server/src/orchestration-v2/messageSearchIndex.ts +++ b/apps/server/src/orchestration-v2/messageSearchIndex.ts @@ -1,13 +1,28 @@ +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as Effect from "effect/Effect"; import type * as SqlClient from "effect/sql/SqlClient"; +// SQLite's ltrim needs the characters matched by isAttachmentOnlyBootstrapPrompt's \s explicitly. +const BOOTSTRAP_PREFIX_WHITESPACE = + "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; + +export const nonPlaceholderMessageCondition = (sql: SqlClient.SqlClient, row = "messages") => + sql.literal(` + CASE + WHEN substr(json_extract(${row}.payload_json, '$.text'), 1, 11) = 'Ultrathink:' COLLATE NOCASE + THEN ltrim(substr(json_extract(${row}.payload_json, '$.text'), 12), '${BOOTSTRAP_PREFIX_WHITESPACE}') + ELSE json_extract(${row}.payload_json, '$.text') + END COLLATE BINARY <> '${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT.replaceAll("'", "''")}' +`); + /** - * Exclude generated notices and subagent threads: search hides them, and ACP subagents rewrite - * their finished answer on every streamed chunk. + * Exclude attachment placeholders, generated notices and subagent threads. ACP subagents + * rewrite their finished answer on every streamed chunk. * Migration triggers, backfill, rebuild and scans share this predicate; `row` may be `new`. */ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "messages") => - sql.literal(` + sql` + ${sql.literal(` ${row}.streaming = 0 AND ( ${row}.role = 'assistant' @@ -21,7 +36,9 @@ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "message WHERE subagent_threads.thread_id = ${row}.thread_id AND json_extract(subagent_threads.payload_json, '$.lineage.relationshipToParent') = 'subagent' ) -`); +`)} + AND ${nonPlaceholderMessageCondition(sql, row)} +`; /** * What the index stores for a message: its text with curly apostrophes @@ -32,6 +49,52 @@ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "message export const indexedMessageText = (sql: SqlClient.SqlClient, payload: string) => sql.literal(`replace(replace(json_extract(${payload}, '$.text'), '’', ''''), '‘', '''')`); +export const createMessageSearchTriggers = Effect.fn("MessageSearchIndex.createTriggers")( + function* (sql: SqlClient.SqlClient) { + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + WHEN ${indexedMessageCondition(sql, "new")} + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + // Streaming deltas rewrite the row many times a second; skip them until the + // message settles. Upserts rewrite every column, so a finished message + // re-indexes only when what the index reads changed. Deleting a rowid the + // index never held is a no-op. + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_update + AFTER UPDATE OF payload_json, streaming, role ON orchestration_v2_projection_messages + WHEN (old.streaming = 0 OR new.streaming = 0) + AND ( + old.streaming IS NOT new.streaming + OR old.role IS NOT new.role + OR json_extract(old.payload_json, '$.text') IS NOT json_extract(new.payload_json, '$.text') + OR json_extract(old.payload_json, '$.createdBy') + IS NOT json_extract(new.payload_json, '$.createdBy') + ) + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid AND old.streaming = 0; + INSERT INTO orchestration_v2_message_search (rowid, text) + SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} + WHERE ${indexedMessageCondition(sql, "new")} + AND json_extract(new.payload_json, '$.text') IS NOT NULL; + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_delete + AFTER DELETE ON orchestration_v2_projection_messages + WHEN old.streaming = 0 + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; + END + `; + }, +); + /** * Refills the search index from the messages table. The index is keyed by * message rowid, so anything that may renumber or copy rows (VACUUM, a dev diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8143a86b15034..6a865a8fc89a9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -76,6 +76,7 @@ import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; +import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -152,6 +153,7 @@ const migrationEntries = [ [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], [73, "ActiveTurnItemIndexes", Migration0073], + [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts index 029218bb67b1b..68e6d66ed3f7f 100644 --- a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.test.ts @@ -1,8 +1,10 @@ import { assert, it } from "@effect/vitest"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; +import { rebuildMessageSearch } from "../../orchestration-v2/messageSearchIndex.ts"; import { runMigrations } from "../Migrations.ts"; const layer = it.layer(NodeSqliteClient.layer({ filename: ":memory:" })); @@ -53,3 +55,86 @@ layer("069_ThreadMessageSearch", (it) => { }), ); }); + +it.effect.each(["inserted", "updated", "backfilled", "rebuilt"])( + "keeps normal messages indexed and excludes %s placeholders", + (path) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: path === "backfilled" ? 68 : 73 }); + if (path !== "backfilled") { + // Pre-upgrade triggers indexed user text even when it was an attachment placeholder. + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_update`; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_insert + AFTER INSERT ON orchestration_v2_projection_messages + BEGIN + INSERT INTO orchestration_v2_message_search (rowid, text) + VALUES (new.rowid, json_extract(new.payload_json, '$.text')); + END + `; + yield* sql` + CREATE TRIGGER orchestration_v2_message_search_update + AFTER UPDATE OF payload_json ON orchestration_v2_projection_messages + BEGIN + DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; + INSERT INTO orchestration_v2_message_search (rowid, text) + VALUES (new.rowid, json_extract(new.payload_json, '$.text')); + END + `; + if (path !== "rebuilt") yield* runMigrations(); + } + const placeholders = [ + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + ...[ + "", + " ", + "\n", + "\t\r\n", + "\v\f", + "\u00a0", + "\u1680", + "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a", + "\u2028\u2029\u202f\u205f\u3000\ufeff", + ].map((whitespace) => `uLtRaThInK:${whitespace}${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`), + ]; + const normal = [ + "normal context", + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT.toLowerCase(), + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT.toLowerCase()}`, + `quoted ${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT} extra`, + ` ${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + `Ultrathink:\u0085${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ]; + for (const [index, text] of [...normal, ...placeholders].entries()) { + const payload = JSON.stringify({ text, createdBy: "user" }); + yield* sql` + INSERT INTO orchestration_v2_projection_messages + (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) + VALUES (${String(index)}, 'thread-1', 'user', 0, + '2026-09-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z', + ${path === "updated" ? JSON.stringify({ text: "normal context" }) : payload}) + `; + if (path === "updated") { + yield* sql` + UPDATE orchestration_v2_projection_messages SET payload_json = ${payload} + WHERE message_id = ${String(index)} + `; + } + } + yield* runMigrations({ toMigrationInclusive: path === "backfilled" ? 69 : undefined }); + if (path === "rebuilt") yield* rebuildMessageSearch(sql); + const indexed = yield* sql<{ readonly messageId: string }>` + SELECT messages.message_id AS "messageId" + FROM orchestration_v2_message_search AS search + INNER JOIN orchestration_v2_projection_messages AS messages ON messages.rowid = search.rowid + ORDER BY messages.rowid + `; + assert.deepEqual( + indexed.map((row) => row.messageId), + normal.map((_, index) => String(index)), + ); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), +); diff --git a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts index 4652debcbfd72..2c527d7e3ea99 100644 --- a/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts +++ b/apps/server/src/persistence/Migrations/069_ThreadMessageSearch.ts @@ -3,6 +3,7 @@ import * as Predicate from "effect/Predicate"; import * as SqlClient from "effect/sql/SqlClient"; import { + createMessageSearchTriggers, indexedMessageCondition, indexedMessageText, } from "../../orchestration-v2/messageSearchIndex.ts"; @@ -39,47 +40,7 @@ export default Effect.gen(function* () { ), ); if (!created) return; - yield* sql` - CREATE TRIGGER orchestration_v2_message_search_insert - AFTER INSERT ON orchestration_v2_projection_messages - WHEN ${indexedMessageCondition(sql, "new")} - BEGIN - INSERT INTO orchestration_v2_message_search (rowid, text) - SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} - WHERE json_extract(new.payload_json, '$.text') IS NOT NULL; - END - `; - // Streaming deltas rewrite the row many times a second; skip them until the - // message settles. Upserts rewrite every column, so a finished message - // re-indexes only when what the index reads changed. Deleting a rowid the - // index never held is a no-op. - yield* sql` - CREATE TRIGGER orchestration_v2_message_search_update - AFTER UPDATE OF payload_json, streaming, role ON orchestration_v2_projection_messages - WHEN (old.streaming = 0 OR new.streaming = 0) - AND ( - old.streaming IS NOT new.streaming - OR old.role IS NOT new.role - OR json_extract(old.payload_json, '$.text') IS NOT json_extract(new.payload_json, '$.text') - OR json_extract(old.payload_json, '$.createdBy') - IS NOT json_extract(new.payload_json, '$.createdBy') - ) - BEGIN - DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid AND old.streaming = 0; - INSERT INTO orchestration_v2_message_search (rowid, text) - SELECT new.rowid, ${indexedMessageText(sql, "new.payload_json")} - WHERE ${indexedMessageCondition(sql, "new")} - AND json_extract(new.payload_json, '$.text') IS NOT NULL; - END - `; - yield* sql` - CREATE TRIGGER orchestration_v2_message_search_delete - AFTER DELETE ON orchestration_v2_projection_messages - WHEN old.streaming = 0 - BEGIN - DELETE FROM orchestration_v2_message_search WHERE rowid = old.rowid; - END - `; + yield* createMessageSearchTriggers(sql); yield* sql` INSERT INTO orchestration_v2_message_search (rowid, text) SELECT messages.rowid, ${indexedMessageText(sql, "messages.payload_json")} diff --git a/apps/server/src/persistence/Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts b/apps/server/src/persistence/Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts new file mode 100644 index 0000000000000..6ec95d108f306 --- /dev/null +++ b/apps/server/src/persistence/Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { createMessageSearchTriggers } from "../../orchestration-v2/messageSearchIndex.ts"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const index = yield* sql` + SELECT 1 FROM sqlite_master WHERE name = 'orchestration_v2_message_search' + `; + if (index.length === 0) return; + + // Legacy placeholders stay indexed until rebuild; FTS deletes can overflow last_insert_rowid at commit. + yield* sql`DROP TRIGGER orchestration_v2_message_search_insert`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_update`; + yield* sql`DROP TRIGGER orchestration_v2_message_search_delete`; + yield* createMessageSearchTriggers(sql); +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index c08062c09d1ad..472c09d728ac4 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -53,6 +53,7 @@ describe("V2 preview upgrade", () => { [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], + [74, "ExcludeAttachmentPlaceholdersFromSearch"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -149,6 +150,7 @@ describe("V2 preview upgrade", () => { [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], [73, "ActiveTurnItemIndexes"], + [74, "ExcludeAttachmentPlaceholdersFromSearch"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bef8f7ab405f1..2e4b2b30929db 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -748,6 +748,7 @@ export function cloneComposerImageForRetry( export function deriveComposerSendState(options: { prompt: string; imageCount: number; + existingAttachmentCount?: number; terminalContexts: ReadonlyArray; elementContextCount?: number; }): { @@ -768,6 +769,7 @@ export function deriveComposerSendState(options: { hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0 || + (options.existingAttachmentCount ?? 0) > 0 || sendableTerminalContexts.length > 0 || elementContextCount > 0, }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d7878cc29a165..ae0bb19336e0e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1091,6 +1091,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerSendState = deriveComposerSendState({ prompt, imageCount: composerImages.length + composerFiles.length, + existingAttachmentCount: editingQueuedAttachments?.length ?? 0, terminalContexts: composerTerminalContexts, elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length, }); diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts index 20d4c75997b1d..776cd39096a8a 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.test.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -1,6 +1,38 @@ import { describe, expect, it } from "vite-plus/test"; +import { MessageId } from "@t3tools/contracts"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; -import { resolveTimelineMinimapPreview } from "./timelineMinimapItems"; +import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems"; + +describe("deriveTimelineMinimapItems", () => { + it.each(["", "Ultrathink:\n"])( + "omits bootstrap text from the minimap label and tooltip (%j)", + (prefix) => { + const at = "2026-10-01T00:00:00Z"; + const items = deriveTimelineMinimapItems([ + { + kind: "message", + id: "image-only", + createdAt: at, + showAssistantMeta: false, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + message: { + id: MessageId.make("image-only"), + role: "user", + text: prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + runId: null, + streaming: false, + createdAt: at, + updatedAt: at, + }, + }, + ]); + expect(items).toEqual([{ id: "image-only", rowIndex: 0, userText: "", assistantText: null }]); + expect(resolveTimelineMinimapPreview(items[0]!)?.userText).toBeNull(); + }, + ); +}); describe("resolveTimelineMinimapPreview", () => { it("previews a thread autolink as prose, not its URL", () => { diff --git a/apps/web/src/components/chat/timelineMinimapItems.ts b/apps/web/src/components/chat/timelineMinimapItems.ts index 099c58379ccf6..3c730df680e54 100644 --- a/apps/web/src/components/chat/timelineMinimapItems.ts +++ b/apps/web/src/components/chat/timelineMinimapItems.ts @@ -1,4 +1,5 @@ import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import type { Nodes } from "mdast"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; @@ -26,7 +27,7 @@ export function deriveTimelineMinimapItems( items.push({ id: row.id, rowIndex: index, - userText: row.message.text, + userText: isAttachmentOnlyBootstrapPrompt(row.message.text) ? "" : row.message.text, assistantText: resolveFinalAssistantTextForTurn(rows, index), }); } diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 9c9ac38e1cb1e..cc6e04885c623 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -1,6 +1,7 @@ import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { EnvironmentId, + ChatAttachmentId, MessageId, RunId, ThreadId, @@ -8,9 +9,13 @@ import { } from "@t3tools/contracts"; import { useImperativeHandle, type RefObject } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { toastManager } from "../ui/toast"; import { act, render } from "../../../test/render"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +import { deriveComposerSendState } from "../ChatView.logic"; +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; import { useQueuedRunEdit } from "./useQueuedRunEdit"; @@ -34,6 +39,10 @@ vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.editQueuedRun, })); +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentIdentificationMode: () => "none", +})); + const runsKey = (ref: ScopedThreadRef) => `${ref.environmentId}:${ref.threadId}`; vi.mock("../../state/threads", async () => { @@ -88,7 +97,34 @@ function Harness(props: { composerRef: { current: null }, }); useImperativeHandle(props.hookRef, () => result); - return null; + const draft = useComposerThreadDraft(result.draftTarget); + const sendState = deriveComposerSendState({ + prompt: draft.prompt, + imageCount: draft.images.length + draft.files.length, + existingAttachmentCount: result.editingAttachments?.length ?? 0, + terminalContexts: draft.terminalContexts, + }); + return result.isEditing ? ( + 0} + isSendBusy={result.isSaving} + sendDisabledReason={null} + isEnvironmentUnavailable={false} + isPreparingWorktree={false} + hasSendableContent={sendState.hasSendableContent} + isEditingQueuedMessage + onSubmitMessage={() => void result.saveEdit(savedContent(draft.prompt), () => {})} + onPreviousPendingQuestion={() => {}} + onInterrupt={() => {}} + onImplementPlanInNewThread={() => {}} + /> + ) : null; } function view(threadRef: ScopedThreadRef, runStatus: string, hook: { current: HookResult | null }) { @@ -141,6 +177,94 @@ const promptOf = (ref: ScopedThreadRef) => useComposerDraftStore.getState().getComposerDraft(ref)?.prompt ?? ""; describe("useQueuedRunEdit", () => { + it.each(["", "Ultrathink:\n"])( + "opens attachment-only edits without the placeholder and preserves effort on save (%j)", + async (prefix) => { + const { hook } = setup(); + mocks.editQueuedRun.mockResolvedValue({ _tag: "Success" }); + await using screen = await render(view(THREAD_A, "queued", hook)); + using toast = vi.spyOn(toastManager, "add"); + const attachments = [ + { + type: "image" as const, + id: ChatAttachmentId.make("kept"), + name: "kept.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ]; + const request = { + runId: RUN, + messageId: MessageId.make("message-1"), + text: prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + attachments, + }; + await act(() => requireHook(hook).beginEdit(request)); + expect( + useComposerDraftStore.getState().getComposerDraft(requireHook(hook).draftTarget)?.prompt ?? + "", + ).toBe(prefix.trimEnd()); + expect(requireHook(hook).editingAttachments?.map(({ attachment }) => attachment)).toEqual( + attachments, + ); + await act(() => + useComposerDraftStore + .getState() + .setRuntimeMode(requireHook(hook).draftTarget, "full-access"), + ); + await act(() => setRuns(THREAD_A, "running")); + expect(toast).not.toHaveBeenCalled(); + expect(requireHook(hook).isEditing).toBe(false); + await act(() => setRuns(THREAD_A, "queued")); + await act(() => requireHook(hook).beginEdit(request)); + const update = screen.getByRole("button", { name: "Update queued message" }); + await expect.element(update).toBeEnabled(); + await update.click(); + await vi.waitFor(() => expect(mocks.editQueuedRun).toHaveBeenCalledOnce()); + expect(mocks.editQueuedRun).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + text: prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + edit: expect.objectContaining({ attachments }), + }), + }), + ); + }, + ); + + it("saves an effort-only prompt without claiming removed attachments remain", async () => { + const { hook } = setup(); + mocks.editQueuedRun.mockResolvedValue({ _tag: "Success" }); + await using screen = await render(view(THREAD_A, "queued", hook)); + await act(() => + requireHook(hook).beginEdit({ + runId: RUN, + messageId: MessageId.make("message-1"), + text: `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + attachments: [ + { + type: "image", + id: ChatAttachmentId.make("removed"), + name: "removed.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ], + }), + ); + await act(() => requireHook(hook).removeEditingAttachment("removed")); + await screen.getByRole("button", { name: "Update queued message" }).click(); + await vi.waitFor(() => expect(mocks.editQueuedRun).toHaveBeenCalledOnce()); + expect(mocks.editQueuedRun).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + text: "Ultrathink:", + edit: expect.objectContaining({ attachments: [] }), + }), + }), + ); + }); + it("keeps an open edit when the user leaves the thread and comes back", async () => { const { hook } = setup(); await using screen = await render(view(THREAD_A, "queued", hook)); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index bf317de6898e7..d9185775ea000 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -18,7 +18,10 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { + restoreAttachmentOnlyBootstrapPrompt, + stripAttachmentOnlyBootstrapPrompt, +} from "@t3tools/shared/composerContextReferences"; import { Atom } from "effect/reactivity"; import type { RefObject } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; @@ -146,7 +149,7 @@ async function submitQueuedRunEdit(input: { input: { threadId: editing.threadId, runId: editing.runId, - text: editText.length === 0 ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : editText, + text: restoreAttachmentOnlyBootstrapPrompt(editText, uploads.length > 0), edit: { messageId: editing.messageId, attachments: uploads, @@ -227,9 +230,10 @@ export function useQueuedRunEdit(input: { if (activeThreadId === null) return; if (editing !== null && editing.runId !== request.runId) clearEditDraft(editing.runId); const target = queuedEditDraftTarget(routeThreadRef, request.runId); + const text = stripAttachmentOnlyBootstrapPrompt(request.text); const store = useComposerDraftStore.getState(); store.clearComposerContent(target); - store.setPrompt(target, request.text); + store.setPrompt(target, text); const threadRef = { environmentId, threadId: activeThreadId }; queuedRunEdits.begin( editKey, @@ -237,7 +241,7 @@ export function useQueuedRunEdit(input: { threadId: activeThreadId, runId: request.runId, messageId: request.messageId, - originalText: request.text, + originalText: text, existingAttachments: request.attachments, context: serverProjection?.messages.find((message) => message.id === request.messageId) ?.context, diff --git a/apps/web/src/components/promptLinks/copyPromptLink.ts b/apps/web/src/components/promptLinks/copyPromptLink.ts index e41c15cc87451..6f2a6762fa4a6 100644 --- a/apps/web/src/components/promptLinks/copyPromptLink.ts +++ b/apps/web/src/components/promptLinks/copyPromptLink.ts @@ -1,4 +1,5 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { type ModelSelection, repositoryGroupingKeyOf, @@ -26,7 +27,7 @@ export function promptLinkFor( readonly model: string | null; }, ): string | null { - const trimmed = prompt.trim(); + const trimmed = isAttachmentOnlyBootstrapPrompt(prompt) ? "" : prompt.trim(); if (trimmed.length === 0 || trimmed.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return null; return buildNewThreadLink({ prompt: trimmed, ...context, origin: configuredHostedAppUrl() }); } diff --git a/packages/provider-core/src/server/threadTitleContext.test.ts b/packages/provider-core/src/server/threadTitleContext.test.ts index 449b3abcdf6cb..253f167c639c0 100644 --- a/packages/provider-core/src/server/threadTitleContext.test.ts +++ b/packages/provider-core/src/server/threadTitleContext.test.ts @@ -1,7 +1,30 @@ import { describe, expect, it } from "vite-plus/test"; +import { ChatAttachmentId } from "@t3tools/contracts"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { formatThreadTitleContext } from "./threadTitleContext.ts"; describe("thread title context", () => { + it.each(["", "Ultrathink:\n"])("keeps attachments without bootstrap text (%j)", (prefix) => { + const attachments = [ + { + type: "image" as const, + id: ChatAttachmentId.make("diagram"), + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ]; + const text = prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT; + expect(formatThreadTitleContext([{ role: "user", text, attachments }])).toEqual({ + message: "USER:\n[Attachments: diagram.png]", + attachments, + }); + expect(formatThreadTitleContext([{ role: "user", text }])).toEqual({ + message: "", + attachments: [], + }); + }); + it("keeps a user's scope change despite long assistant output", () => { const result = formatThreadTitleContext([ { role: "user", text: "Review QR sharing" }, diff --git a/packages/provider-core/src/server/threadTitleContext.ts b/packages/provider-core/src/server/threadTitleContext.ts index 7d188a4a39de0..f1840891e96bb 100644 --- a/packages/provider-core/src/server/threadTitleContext.ts +++ b/packages/provider-core/src/server/threadTitleContext.ts @@ -1,5 +1,6 @@ import type { ChatAttachment } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; export type ThreadTitleMessage = { readonly role: "user" | "assistant" | "system" | "reasoning"; @@ -22,7 +23,8 @@ export function limitTitleMessage(text: string, budget: number): string { } export function formatThreadTitleContext(messages: ReadonlyArray) { - const sections = messages.flatMap((message, index) => { + const sections = messages.flatMap((source, index) => { + const message = isAttachmentOnlyBootstrapPrompt(source.text) ? { ...source, text: "" } : source; // Thinking traces dwarf answers without identifying the thread subject. if ( message.role === "system" || diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index 7212a1b64a984..7356c525308c2 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -7,11 +7,14 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, collectComposerContextReferences, formatComposerContextHref, isImageReferencesOnly, parseComposerContextHref, projectComposerContextForProvider, + restoreAttachmentOnlyBootstrapPrompt, + stripAttachmentOnlyBootstrapPrompt, threadContextRecord, threadContextsOutsideEnvironment, toComposerContextId, @@ -84,6 +87,38 @@ describe("labels and reference links", () => { }); }); +describe("attachment-only bootstrap prompts", () => { + it.each(["", "Ultrathink:\n", "ultrathink: \n"])( + "removes only the placeholder while keeping effort (%j)", + (prefix) => { + expect(stripAttachmentOnlyBootstrapPrompt(prefix + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT)).toBe( + prefix.trimEnd(), + ); + const text = `${prefix}Explain ${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`; + expect(stripAttachmentOnlyBootstrapPrompt(text)).toBe(text); + }, + ); + + it.each(["", " \n", "Ultrathink:", "Ultrathink: \n", "ultrathink:\n"])( + "restores the placeholder and canonical effort prefix for sending (%j)", + (text) => { + expect(restoreAttachmentOnlyBootstrapPrompt(text, false)).toBe(text); + expect(restoreAttachmentOnlyBootstrapPrompt(text, true)).toBe( + (text.trim() ? "Ultrathink:\n" : "") + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + ); + }, + ); + + it.each([ + "Describe the file", + " Ultrathink:\nDescribe the file ", + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}`, + ])("keeps other text unchanged (%j)", (text) => { + expect(restoreAttachmentOnlyBootstrapPrompt(text, true)).toBe(text); + }); +}); + describe("provider projection", () => { const terminal: ComposerContextRecord = { version: 1, diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 88361606a9a64..a76145ffddc68 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -137,6 +137,24 @@ export function isAttachmentOnlyBootstrapPrompt(text: string): boolean { return text.replace(/^Ultrathink:\s*/i, "") === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT; } +export function stripAttachmentOnlyBootstrapPrompt(text: string): string { + return isAttachmentOnlyBootstrapPrompt(text) + ? text.slice(0, -ATTACHMENT_ONLY_BOOTSTRAP_PROMPT.length).trimEnd() + : text; +} + +export function restoreAttachmentOnlyBootstrapPrompt( + text: string, + hasAttachments: boolean, +): string { + if (!hasAttachments) return text; + const trimmed = text.trim(); + if (trimmed.length === 0) return ATTACHMENT_ONLY_BOOTSTRAP_PROMPT; + return /^Ultrathink:\s*$/i.test(trimmed) + ? `Ultrathink:\n${ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}` + : text; +} + export function isImageReferencesOnly(text: string): boolean { const references = collectComposerContextReferences(text); return (