Skip to content
Merged
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
28 changes: 28 additions & 0 deletions apps/mobile/src/state/queued-run-edit.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<ReadonlyArray<{ readonly id: RunId; readonly status: string }> | 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");
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/state/queued-run-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
123 changes: 123 additions & 0 deletions apps/server/src/orchestration-v2/ThreadSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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* () {
Expand All @@ -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"),
);
}
}
}),
);
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/orchestration-v2/ThreadSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { assert, describe, it, vi } from "@effect/vitest";
import {
CommandId,
ChatAttachmentId,
type ChatAttachment,
EventId,
MessageId,
ProjectId,
Expand All @@ -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";
Expand Down Expand Up @@ -134,6 +137,7 @@ function dispatchUserMessage(input: {
readonly command: string;
readonly threadId: ThreadId;
readonly text: string;
readonly attachments?: ReadonlyArray<ChatAttachment>;
}) {
return Effect.gen(function* () {
const threads = yield* ThreadManagement.ThreadManagementService;
Expand All @@ -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",
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences";
import {
CommandId,
type ChatAttachment,
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading