Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { AgentConversationVM } from "../../../shared/agentConversation/contracts/agentConversationVM";
|
|
import {
|
|
agentComposerDraftFiles,
|
|
agentComposerDraftImages,
|
|
agentComposerDraftLargeTexts,
|
|
agentComposerDraftPrompt,
|
|
agentComposerDraftToPromptContent,
|
|
buildAgentComposerDraft,
|
|
emptyAgentComposerDraft
|
|
} from "./agentComposerDraft";
|
|
import { createAgentComposerFileMentionMarkdown } from "../agentRichText/agentMentionMarkdown";
|
|
import {
|
|
EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE,
|
|
navigateAgentComposerInputHistory,
|
|
projectAgentComposerInputHistory,
|
|
resolvePendingAgentComposerInputHistory
|
|
} from "./agentComposerInputHistory";
|
|
|
|
describe("agent composer input history", () => {
|
|
it("projects structured user input and keeps the newest adjacent duplicate", () => {
|
|
const conversation = conversationWithUserMessages([
|
|
{
|
|
id: "message-1",
|
|
body: "expanded prompt",
|
|
sourceTimelineItems: [
|
|
{
|
|
payload: {
|
|
content: [
|
|
{ type: "text", text: "expanded prompt" },
|
|
{
|
|
type: "image",
|
|
attachmentId: "attachment-1",
|
|
mimeType: "image/png",
|
|
name: "one.png"
|
|
}
|
|
],
|
|
displayPrompt: "original prompt"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: "message-2",
|
|
body: "original prompt",
|
|
sourceTimelineItems: [
|
|
{
|
|
payload: {
|
|
content: [
|
|
{ type: "text", text: "expanded prompt" },
|
|
{
|
|
type: "image",
|
|
attachmentId: "attachment-1",
|
|
mimeType: "image/png",
|
|
name: "one.png"
|
|
}
|
|
],
|
|
displayPrompt: "original prompt"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]);
|
|
|
|
const history = projectAgentComposerInputHistory(conversation);
|
|
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0]!.id).toBe("turn-1:message-2");
|
|
expect(agentComposerDraftPrompt(history[0]!.draft)).toBe("expanded prompt");
|
|
expect(agentComposerDraftImages(history[0]!.draft)).toEqual([
|
|
expect.objectContaining({
|
|
attachmentId: "attachment-1",
|
|
previewUrl: ""
|
|
})
|
|
]);
|
|
});
|
|
|
|
it("includes Goal controls in chronological input history", () => {
|
|
const conversation = conversationWithUserMessages([
|
|
{
|
|
id: "message-1",
|
|
body: "first prompt",
|
|
occurredAtUnixMs: 100
|
|
},
|
|
{
|
|
id: "message-2",
|
|
body: "second prompt",
|
|
occurredAtUnixMs: 300
|
|
}
|
|
]);
|
|
conversation.sourceDetail.goalControls = [
|
|
{
|
|
id: "goal-control-1",
|
|
action: "set",
|
|
body: "/goal ship it",
|
|
occurredAtUnixMs: 200
|
|
}
|
|
];
|
|
|
|
const history = projectAgentComposerInputHistory(conversation);
|
|
|
|
expect(
|
|
history.map((entry) => agentComposerDraftPrompt(entry.draft))
|
|
).toEqual(["first prompt", "/goal ship it", "second prompt"]);
|
|
|
|
const latest = navigateAgentComposerInputHistory({
|
|
currentDraft: emptyAgentComposerDraft(),
|
|
direction: "older",
|
|
entries: history,
|
|
hasOlderPage: false,
|
|
state: EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE
|
|
});
|
|
const goal = navigateAgentComposerInputHistory({
|
|
currentDraft: latest.draft!,
|
|
direction: "older",
|
|
entries: history,
|
|
hasOlderPage: false,
|
|
state: latest.state
|
|
});
|
|
|
|
expect(agentComposerDraftPrompt(goal.draft!)).toBe("/goal ship it");
|
|
});
|
|
|
|
it("does not restore a synthetic image-only display prompt as text", () => {
|
|
const history = projectAgentComposerInputHistory(
|
|
conversationWithUserMessages([
|
|
{
|
|
id: "image-message",
|
|
body: "[Image]",
|
|
sourceTimelineItems: [
|
|
{
|
|
payload: {
|
|
content: [
|
|
{
|
|
type: "image",
|
|
path: "/tmp/image.png",
|
|
mimeType: "image/png"
|
|
}
|
|
],
|
|
displayPrompt: "[Image]"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
])
|
|
);
|
|
|
|
expect(agentComposerDraftPrompt(history[0]!.draft)).toBe("");
|
|
expect(agentComposerDraftImages(history[0]!.draft)).toHaveLength(1);
|
|
});
|
|
|
|
it("restores a file-only structured input with a resendable mention", () => {
|
|
const displayPrompt = createAgentComposerFileMentionMarkdown({
|
|
id: "original-file",
|
|
name: "report.pdf",
|
|
status: "ready"
|
|
});
|
|
const history = projectAgentComposerInputHistory(
|
|
conversationWithUserMessages([
|
|
{
|
|
id: "file-message",
|
|
body: displayPrompt,
|
|
sourceTimelineItems: [
|
|
{
|
|
payload: {
|
|
content: [
|
|
{
|
|
type: "file",
|
|
kind: "file",
|
|
name: "report.pdf",
|
|
path: "/runtime/report.pdf"
|
|
}
|
|
],
|
|
displayPrompt
|
|
}
|
|
}
|
|
]
|
|
}
|
|
])
|
|
);
|
|
|
|
expect(history).toHaveLength(1);
|
|
expect(agentComposerDraftFiles(history[0]!.draft)).toHaveLength(1);
|
|
expect(
|
|
agentComposerDraftToPromptContent({
|
|
draft: history[0]!.draft,
|
|
skills: []
|
|
})
|
|
).toEqual([
|
|
{
|
|
type: "text",
|
|
text: "[@report.pdf](/runtime/report.pdf)"
|
|
}
|
|
]);
|
|
});
|
|
|
|
it("restores mixed text and file input without dropping the file on resend", () => {
|
|
const fileMention = createAgentComposerFileMentionMarkdown({
|
|
id: "original-file",
|
|
name: "report.pdf",
|
|
status: "ready"
|
|
});
|
|
const history = projectAgentComposerInputHistory(
|
|
conversationWithUserMessages([
|
|
{
|
|
id: "mixed-message",
|
|
body: `Summarize${fileMention}`,
|
|
sourceTimelineItems: [
|
|
{
|
|
payload: {
|
|
content: [
|
|
{ type: "text", text: "Summarize" },
|
|
{
|
|
type: "file",
|
|
kind: "file",
|
|
name: "report.pdf",
|
|
path: "/runtime/report.pdf"
|
|
}
|
|
],
|
|
displayPrompt: `Summarize${fileMention}`
|
|
}
|
|
}
|
|
]
|
|
}
|
|
])
|
|
);
|
|
|
|
expect(agentComposerDraftFiles(history[0]!.draft)).toHaveLength(1);
|
|
expect(
|
|
agentComposerDraftToPromptContent({
|
|
draft: history[0]!.draft,
|
|
skills: []
|
|
})
|
|
).toEqual([
|
|
{
|
|
type: "text",
|
|
text: "Summarize[@report.pdf](/runtime/report.pdf)"
|
|
}
|
|
]);
|
|
});
|
|
|
|
it("restores pasted text without resending its display mention as text", () => {
|
|
const history = projectAgentComposerInputHistory(
|
|
conversationWithUserMessages([
|
|
{
|
|
id: "pasted-text-message",
|
|
body: "Summarize this",
|
|
sourceTimelineItems: [
|
|
{
|
|
payload: {
|
|
content: [
|
|
{ type: "text", text: "Summarize this" },
|
|
{
|
|
type: "file",
|
|
kind: "pasted-text",
|
|
name: "first line…",
|
|
path: "/archive/aa/deadbeef.txt",
|
|
sizeBytes: 22
|
|
}
|
|
],
|
|
displayPrompt:
|
|
"Summarize this\n[@first line…](mention://pasted-text/original-paste?path=%2Farchive%2Faa%2Fdeadbeef.txt&size=22)"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
])
|
|
);
|
|
|
|
expect(agentComposerDraftLargeTexts(history[0]!.draft)).toHaveLength(1);
|
|
expect(
|
|
agentComposerDraftToPromptContent({
|
|
draft: history[0]!.draft,
|
|
skills: []
|
|
})
|
|
).toEqual([
|
|
{ type: "text", text: "Summarize this" },
|
|
{
|
|
type: "file",
|
|
kind: "pasted-text",
|
|
name: "first line…",
|
|
path: "/archive/aa/deadbeef.txt",
|
|
sizeBytes: 22
|
|
}
|
|
]);
|
|
});
|
|
|
|
it("navigates from empty to older entries and clears past the newest", () => {
|
|
const entries = [
|
|
{ id: "one", draft: buildAgentComposerDraft({ prompt: "one" }) },
|
|
{ id: "two", draft: buildAgentComposerDraft({ prompt: "two" }) }
|
|
];
|
|
const latest = navigateAgentComposerInputHistory({
|
|
currentDraft: emptyAgentComposerDraft(),
|
|
direction: "older",
|
|
entries,
|
|
hasOlderPage: false,
|
|
state: EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE
|
|
});
|
|
const older = navigateAgentComposerInputHistory({
|
|
currentDraft: latest.draft!,
|
|
direction: "older",
|
|
entries,
|
|
hasOlderPage: false,
|
|
state: latest.state
|
|
});
|
|
const newer = navigateAgentComposerInputHistory({
|
|
currentDraft: older.draft!,
|
|
direction: "newer",
|
|
entries,
|
|
hasOlderPage: false,
|
|
state: older.state
|
|
});
|
|
const cleared = navigateAgentComposerInputHistory({
|
|
currentDraft: newer.draft!,
|
|
direction: "newer",
|
|
entries,
|
|
hasOlderPage: false,
|
|
state: newer.state
|
|
});
|
|
|
|
expect(agentComposerDraftPrompt(latest.draft!)).toBe("two");
|
|
expect(agentComposerDraftPrompt(older.draft!)).toBe("one");
|
|
expect(agentComposerDraftPrompt(newer.draft!)).toBe("two");
|
|
expect(cleared.state.entryId).toBeNull();
|
|
expect(agentComposerDraftPrompt(cleared.draft!)).toBe("");
|
|
});
|
|
|
|
it("leaves a typed or edited draft to normal arrow-key behavior", () => {
|
|
const entries = [
|
|
{ id: "one", draft: buildAgentComposerDraft({ prompt: "one" }) }
|
|
];
|
|
|
|
expect(
|
|
navigateAgentComposerInputHistory({
|
|
currentDraft: buildAgentComposerDraft({ prompt: "typed" }),
|
|
direction: "older",
|
|
entries,
|
|
hasOlderPage: false,
|
|
state: EMPTY_AGENT_COMPOSER_INPUT_HISTORY_STATE
|
|
}).handled
|
|
).toBe(false);
|
|
expect(
|
|
navigateAgentComposerInputHistory({
|
|
currentDraft: buildAgentComposerDraft({ prompt: "edited" }),
|
|
direction: "older",
|
|
entries,
|
|
hasOlderPage: false,
|
|
state: { entryId: "one", pendingOlderPage: false }
|
|
})
|
|
).toMatchObject({
|
|
handled: false,
|
|
state: { entryId: null }
|
|
});
|
|
});
|
|
|
|
it("starts again from the newest entry after a recalled draft is cleared", () => {
|
|
const entries = [
|
|
{ id: "one", draft: buildAgentComposerDraft({ prompt: "one" }) },
|
|
{ id: "two", draft: buildAgentComposerDraft({ prompt: "two" }) }
|
|
];
|
|
|
|
const navigation = navigateAgentComposerInputHistory({
|
|
currentDraft: emptyAgentComposerDraft(),
|
|
direction: "older",
|
|
entries,
|
|
hasOlderPage: false,
|
|
state: { entryId: "one", pendingOlderPage: false }
|
|
});
|
|
|
|
expect(navigation.state.entryId).toBe("two");
|
|
expect(agentComposerDraftPrompt(navigation.draft!)).toBe("two");
|
|
});
|
|
|
|
it("requests an older page and resolves to the prepended entry", () => {
|
|
const current = {
|
|
id: "current",
|
|
draft: buildAgentComposerDraft({ prompt: "current" })
|
|
};
|
|
const pending = navigateAgentComposerInputHistory({
|
|
currentDraft: current.draft,
|
|
direction: "older",
|
|
entries: [current],
|
|
hasOlderPage: true,
|
|
state: { entryId: current.id, pendingOlderPage: false }
|
|
});
|
|
const older = {
|
|
id: "older",
|
|
draft: buildAgentComposerDraft({ prompt: "older" })
|
|
};
|
|
const resolved = resolvePendingAgentComposerInputHistory({
|
|
entries: [older, current],
|
|
state: pending.state
|
|
});
|
|
|
|
expect(pending).toMatchObject({
|
|
handled: true,
|
|
requestOlderPage: true,
|
|
state: { pendingOlderPage: true }
|
|
});
|
|
expect(resolved?.state.entryId).toBe("older");
|
|
expect(agentComposerDraftPrompt(resolved!.draft!)).toBe("older");
|
|
});
|
|
});
|
|
|
|
function conversationWithUserMessages(
|
|
messages: Array<{
|
|
id: string;
|
|
body: string;
|
|
occurredAtUnixMs?: number | null;
|
|
sourceTimelineItems?: Array<{
|
|
payload: Record<string, unknown>;
|
|
}>;
|
|
}>
|
|
): AgentConversationVM {
|
|
return {
|
|
sourceDetail: {
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
userMessages: messages
|
|
}
|
|
]
|
|
}
|
|
} as unknown as AgentConversationVM;
|
|
}
|