kcg-code 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kartu pertanyaan (question) — render statis: struktur, teks, dan
|
|
3
|
+
* aksesibilitas. Bentuk respon tiap interaksi diverifikasi di browser.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
7
|
+
import type { InteractivePrompt, PromptQuestion } from "@/types";
|
|
8
|
+
import { PromptCard } from "../PromptCard";
|
|
9
|
+
|
|
10
|
+
const opts = [
|
|
11
|
+
{ label: "Permission prompt fiktif", description: "Contoh permintaan izin tool." },
|
|
12
|
+
{ label: "JSON-RPC / format API", description: "Struktur payload JSON contoh." },
|
|
13
|
+
{ label: "Script nyata", description: null },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function question(over: Partial<InteractivePrompt> = {}): InteractivePrompt {
|
|
17
|
+
return {
|
|
18
|
+
id: "que_1",
|
|
19
|
+
sessionId: "s1",
|
|
20
|
+
kind: "question",
|
|
21
|
+
type: "menu",
|
|
22
|
+
title: "Simulasi permission tool yang seperti apa yang kamu mau?",
|
|
23
|
+
options: opts,
|
|
24
|
+
custom: true,
|
|
25
|
+
status: "pending",
|
|
26
|
+
createdAt: 0,
|
|
27
|
+
resolvedAt: null,
|
|
28
|
+
...over,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function html(p: InteractivePrompt, docked = false): string {
|
|
33
|
+
return renderToStaticMarkup(<PromptCard prompts={[p]} onResolve={() => {}} docked={docked} />);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("QuestionCard — single choice", () => {
|
|
37
|
+
test("judul = pertanyaannya sendiri (bukan label 'Question'), teks biasa bukan monospace", () => {
|
|
38
|
+
const out = html(question());
|
|
39
|
+
expect(out).toContain("Simulasi permission tool yang seperti apa yang kamu mau?");
|
|
40
|
+
expect(out).not.toContain(">Question<");
|
|
41
|
+
expect(out).not.toContain("Pick one of the options");
|
|
42
|
+
// Pertanyaan tidak lagi ditampilkan dalam kotak font-mono.
|
|
43
|
+
expect(out).not.toMatch(/font-mono[^>]*>Simulasi/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("opsi: label + keterangan, petunjuk 'ketuk untuk menjawab', tanpa tombol Submit", () => {
|
|
47
|
+
const out = html(question());
|
|
48
|
+
for (const o of opts) expect(out).toContain(o.label);
|
|
49
|
+
expect(out).toContain("Contoh permintaan izin tool.");
|
|
50
|
+
expect(out).toContain("Tap an option to answer");
|
|
51
|
+
expect(out).not.toContain(">Submit<");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("opsi bernomor + pintasan angka 1..n", () => {
|
|
55
|
+
const out = html(question());
|
|
56
|
+
expect(out).toContain('aria-keyshortcuts="1"');
|
|
57
|
+
expect(out).toContain('aria-keyshortcuts="3"');
|
|
58
|
+
expect(out).not.toContain('aria-keyshortcuts="4"');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("selalu ada Skip untuk menolak tanpa memilih", () => {
|
|
62
|
+
expect(html(question())).toContain(">Skip<");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("jawaban bebas: input berlabel + tombol kirim", () => {
|
|
66
|
+
const out = html(question());
|
|
67
|
+
expect(out).toContain('placeholder="Type your own answer…"');
|
|
68
|
+
expect(out).toContain('aria-label="Send your answer"');
|
|
69
|
+
// React SSR mempertahankan nama prop apa adanya (`enterKeyHint`).
|
|
70
|
+
expect(out.toLowerCase()).toContain('enterkeyhint="send"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("custom=false -> tanpa input jawaban bebas", () => {
|
|
74
|
+
expect(html(question({ custom: false }))).not.toContain("Type your own answer");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("aksesibilitas: dialog berlabel judul pertanyaan", () => {
|
|
78
|
+
const out = html(question());
|
|
79
|
+
expect(out).toContain('role="dialog"');
|
|
80
|
+
expect(out).toContain('aria-labelledby="q-que_1-title"');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("QuestionCard — multi-select", () => {
|
|
85
|
+
test("checkbox per opsi + tombol Submit; jawaban bebas ikut Submit (tanpa tombol kirim sendiri)", () => {
|
|
86
|
+
const out = html(question({ multiple: true }));
|
|
87
|
+
expect(out.match(/role="checkbox"/g)).toHaveLength(opts.length);
|
|
88
|
+
expect(out).toContain("Select all that apply");
|
|
89
|
+
expect(out).toContain(">Submit<");
|
|
90
|
+
expect(out).not.toContain('aria-label="Send your answer"');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("Submit nonaktif sampai ada pilihan", () => {
|
|
94
|
+
expect(html(question({ multiple: true }))).toMatch(/disabled=""[^>]*>Submit</);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("QuestionCard — multi-question", () => {
|
|
99
|
+
const qs: PromptQuestion[] = [
|
|
100
|
+
{
|
|
101
|
+
question: "Bahasa apa?",
|
|
102
|
+
header: "Language",
|
|
103
|
+
options: [
|
|
104
|
+
{ label: "TS", description: null },
|
|
105
|
+
{ label: "Go", description: null },
|
|
106
|
+
],
|
|
107
|
+
multiple: false,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
question: "Fitur apa saja?",
|
|
111
|
+
options: [
|
|
112
|
+
{ label: "Auth", description: null },
|
|
113
|
+
{ label: "Logs", description: null },
|
|
114
|
+
],
|
|
115
|
+
multiple: true,
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
test("wizard: judul ringkas + posisi langkah + indikator per pertanyaan", () => {
|
|
120
|
+
const out = html(question({ questions: qs }));
|
|
121
|
+
expect(out).toContain("The agent has 2 questions");
|
|
122
|
+
expect(out).toContain("Question 1 of 2 · 0 answered");
|
|
123
|
+
expect(out).toContain('aria-label="Questions"');
|
|
124
|
+
expect(out).toContain('aria-current="step"');
|
|
125
|
+
// Label segmen memakai header pertanyaan bila ada.
|
|
126
|
+
expect(out).toContain('aria-label="Language"');
|
|
127
|
+
expect(out).toContain('aria-label="Question 2"');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("wizard: semua langkah ditumpuk (tinggi stabil), hanya langkah aktif yang interaktif", () => {
|
|
131
|
+
const out = html(question({ questions: qs }));
|
|
132
|
+
// Kedua langkah ada di DOM dalam satu sel grid -> tinggi kartu tetap.
|
|
133
|
+
expect(out).toContain("Bahasa apa?");
|
|
134
|
+
expect(out).toContain("Fitur apa saja?");
|
|
135
|
+
expect(out.match(/col-start-1 row-start-1/g)).toHaveLength(2);
|
|
136
|
+
// Langkah 2 (bukan aktif) tidak bisa difokus & disembunyikan dari AT.
|
|
137
|
+
const step2 = out.slice(out.indexOf('aria-labelledby="q-que_1-1"') - 200);
|
|
138
|
+
expect(step2).toMatch(/inert=""/);
|
|
139
|
+
expect(step2).toMatch(/aria-hidden="true"/);
|
|
140
|
+
// Langkah 1 single = radio, langkah 2 multiple = checkbox.
|
|
141
|
+
expect(out.match(/role="radio"/g)).toHaveLength(2);
|
|
142
|
+
expect(out.match(/role="checkbox"/g)).toHaveLength(2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("wizard langkah pertama: Back nonaktif, Next nonaktif sampai dijawab, belum ada Submit", () => {
|
|
146
|
+
const out = html(question({ questions: qs }));
|
|
147
|
+
expect(out).toMatch(/disabled=""[^>]*aria-label="Previous question"/);
|
|
148
|
+
expect(out).toMatch(/disabled=""[^>]*>Next/);
|
|
149
|
+
expect(out).not.toContain("Submit answers");
|
|
150
|
+
expect(out).toContain(">Skip<");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("wizard: pintasan angka hanya pada langkah aktif", () => {
|
|
154
|
+
const out = html(question({ questions: qs }));
|
|
155
|
+
// Langkah aktif punya 2 opsi -> masing-masing 1x; langkah lain tanpa.
|
|
156
|
+
expect(out.match(/aria-keyshortcuts="1"/g)).toHaveLength(1);
|
|
157
|
+
expect(out.match(/aria-keyshortcuts="2"/g)).toHaveLength(1);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("docked: daftar jawaban jadi area scroll, footer tetap di luar", () => {
|
|
162
|
+
const out = html(question(), true);
|
|
163
|
+
expect(out).toContain("overflow-y-auto");
|
|
164
|
+
// Footer (Skip) berada SETELAH area jawaban yang scroll.
|
|
165
|
+
expect(out.indexOf("overflow-y-auto")).toBeLessThan(out.indexOf(">Skip<"));
|
|
166
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kerangka loading percakapan — render statis.
|
|
3
|
+
*/
|
|
4
|
+
import { expect, test } from "bun:test";
|
|
5
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
6
|
+
import { SessionComposerSkeleton, SessionTimelineSkeleton } from "../SessionTimelineSkeleton";
|
|
7
|
+
|
|
8
|
+
test("kerangka timeline: status berlabel untuk pembaca layar, bukan empty-state", () => {
|
|
9
|
+
const out = renderToStaticMarkup(<SessionTimelineSkeleton />);
|
|
10
|
+
expect(out).toContain('role="status"');
|
|
11
|
+
expect(out).toContain('aria-label="Loading conversation"');
|
|
12
|
+
expect(out).not.toContain("No conversation yet");
|
|
13
|
+
// Gelembung user (kanan) + baris jawaban assistant.
|
|
14
|
+
expect(out).toContain("ml-auto");
|
|
15
|
+
expect(out.match(/data-slot="skeleton"/g)?.length).toBeGreaterThanOrEqual(5);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("kerangka muncul setelah jeda (anti kedip) dan menghormati reduced motion", () => {
|
|
19
|
+
const out = renderToStaticMarkup(<SessionTimelineSkeleton />);
|
|
20
|
+
expect(out).toContain("delay-150");
|
|
21
|
+
expect(out).toContain("motion-reduce:animate-none");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("kerangka composer meniru composer asli: kotak p-2 + baris setinggi size-9", () => {
|
|
25
|
+
const out = renderToStaticMarkup(<SessionComposerSkeleton />);
|
|
26
|
+
expect(out).toContain("max-w-[46rem]");
|
|
27
|
+
expect(out).toContain("rounded-xl border");
|
|
28
|
+
expect(out).toContain("p-2");
|
|
29
|
+
expect(out.match(/size-9/g)).toHaveLength(2);
|
|
30
|
+
// Tidak ada tinggi tetap yang lebih besar dari composer asli.
|
|
31
|
+
expect(out).not.toMatch(/h-\[6\.5rem\]/);
|
|
32
|
+
});
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test renderer pesan `SessionView.tsx` — bagian logika murni.
|
|
3
|
+
*
|
|
4
|
+
* Fokus: `textOf` — teks jawaban harus berasal dari part `text` SAJA.
|
|
5
|
+
* Part `reasoning`/`tool`/`step` dari opencode juga membawa field `text`;
|
|
6
|
+
* tanpa filter tipe, kalimat thinking ikut tergabung ke jawaban (regression
|
|
7
|
+
* yang pernah terjadi di sesi headless).
|
|
8
|
+
*/
|
|
9
|
+
import { expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
groupTurns,
|
|
12
|
+
hasVisibleContent,
|
|
13
|
+
textOf,
|
|
14
|
+
toolLabel,
|
|
15
|
+
turnSegments,
|
|
16
|
+
turnStatus,
|
|
17
|
+
upsertMessage,
|
|
18
|
+
upsertMessagePart,
|
|
19
|
+
} from "@/lib/turns";
|
|
20
|
+
import type { MessagePart, SessionMessage } from "@/types";
|
|
21
|
+
|
|
22
|
+
test("textOf: hanya part text yang masuk jawaban (reasoning tidak ikut)", () => {
|
|
23
|
+
const parts: MessagePart[] = [
|
|
24
|
+
{ type: "reasoning", text: "Saya berpikir langkah demi langkah..." },
|
|
25
|
+
{ type: "step-start" },
|
|
26
|
+
{ type: "text", text: "Jawaban: 12 * 8 = 96" },
|
|
27
|
+
{ type: "step-finish" },
|
|
28
|
+
];
|
|
29
|
+
expect(textOf(parts)).toBe("Jawaban: 12 * 8 = 96");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("textOf: beberapa part text digabung dengan newline", () => {
|
|
33
|
+
const parts: MessagePart[] = [
|
|
34
|
+
{ type: "text", text: "baris satu" },
|
|
35
|
+
{ type: "reasoning", text: "rahasia" },
|
|
36
|
+
{ type: "text", text: "baris dua" },
|
|
37
|
+
];
|
|
38
|
+
expect(textOf(parts)).toBe("baris satu\nbaris dua");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("textOf: tool part dengan field text tidak ikut", () => {
|
|
42
|
+
const parts: MessagePart[] = [
|
|
43
|
+
{ type: "tool", tool: "bash", text: "cat package.json" },
|
|
44
|
+
{ type: "text", text: "Selesai." },
|
|
45
|
+
];
|
|
46
|
+
expect(textOf(parts)).toBe("Selesai.");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("textOf: tanpa part text -> string kosong", () => {
|
|
50
|
+
const parts: MessagePart[] = [
|
|
51
|
+
{ type: "reasoning", text: "hanya berpikir" },
|
|
52
|
+
{ type: "step-start" },
|
|
53
|
+
];
|
|
54
|
+
expect(textOf(parts)).toBe("");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("upsertMessagePart: pesan belum ada -> placeholder streaming", () => {
|
|
58
|
+
const next = upsertMessagePart([], "s1", "msg_1", { type: "text", id: "prt_a", text: "halo" });
|
|
59
|
+
expect(next).toHaveLength(1);
|
|
60
|
+
expect(next[0]).toMatchObject({
|
|
61
|
+
id: "msg_1",
|
|
62
|
+
sessionId: "s1",
|
|
63
|
+
role: "assistant",
|
|
64
|
+
streaming: true,
|
|
65
|
+
});
|
|
66
|
+
expect(next[0]?.parts).toEqual([{ type: "text", id: "prt_a", text: "halo" }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("upsertMessagePart: part yang sama diganti, part baru ditambahkan", () => {
|
|
70
|
+
const base: SessionMessage[] = [
|
|
71
|
+
{
|
|
72
|
+
id: "msg_1",
|
|
73
|
+
sessionId: "s1",
|
|
74
|
+
role: "assistant",
|
|
75
|
+
parts: [{ type: "text", id: "prt_a", text: "ha" }],
|
|
76
|
+
createdAt: 1,
|
|
77
|
+
streaming: true,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
// Part teks bertambah (streaming) -> ganti part yang sama.
|
|
81
|
+
const grown = upsertMessagePart(base, "s1", "msg_1", {
|
|
82
|
+
type: "text",
|
|
83
|
+
id: "prt_a",
|
|
84
|
+
text: "halo dunia",
|
|
85
|
+
});
|
|
86
|
+
expect(grown[0]?.parts).toEqual([{ type: "text", id: "prt_a", text: "halo dunia" }]);
|
|
87
|
+
expect(grown[0]?.streaming).toBe(true);
|
|
88
|
+
|
|
89
|
+
// Part reasoning baru muncul -> ditambahkan.
|
|
90
|
+
const withReason = upsertMessagePart(grown, "s1", "msg_1", {
|
|
91
|
+
type: "reasoning",
|
|
92
|
+
id: "prt_r",
|
|
93
|
+
text: "pikir...",
|
|
94
|
+
});
|
|
95
|
+
expect(withReason[0]?.parts.map((p) => p.id)).toEqual(["prt_a", "prt_r"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("upsertMessagePart: part tanpa id selalu ditambahkan", () => {
|
|
99
|
+
const base: SessionMessage[] = [
|
|
100
|
+
{
|
|
101
|
+
id: "msg_1",
|
|
102
|
+
sessionId: "s1",
|
|
103
|
+
role: "assistant",
|
|
104
|
+
parts: [{ type: "step-start" }],
|
|
105
|
+
createdAt: 1,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
const next = upsertMessagePart(base, "s1", "msg_1", { type: "text", text: "x" });
|
|
109
|
+
expect(next[0]?.parts).toHaveLength(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("upsertMessage: id sama -> replace versi streaming dengan final", () => {
|
|
113
|
+
const streaming: SessionMessage = {
|
|
114
|
+
id: "msg_1",
|
|
115
|
+
sessionId: "s1",
|
|
116
|
+
role: "assistant",
|
|
117
|
+
parts: [{ type: "text", id: "prt_a", text: "parsial" }],
|
|
118
|
+
createdAt: 1,
|
|
119
|
+
streaming: true,
|
|
120
|
+
};
|
|
121
|
+
const final: SessionMessage = {
|
|
122
|
+
id: "msg_1",
|
|
123
|
+
sessionId: "s1",
|
|
124
|
+
role: "assistant",
|
|
125
|
+
parts: [{ type: "step-start" }, { type: "text", id: "prt_a", text: "lengkap" }],
|
|
126
|
+
createdAt: 1,
|
|
127
|
+
};
|
|
128
|
+
const next = upsertMessage([streaming], final);
|
|
129
|
+
expect(next).toHaveLength(1);
|
|
130
|
+
expect(next[0]?.streaming).toBeUndefined();
|
|
131
|
+
expect(next[0]?.parts).toHaveLength(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("upsertMessage: id baru -> ditambahkan", () => {
|
|
135
|
+
const existing: SessionMessage = {
|
|
136
|
+
id: "msg_1",
|
|
137
|
+
sessionId: "s1",
|
|
138
|
+
role: "assistant",
|
|
139
|
+
parts: [],
|
|
140
|
+
createdAt: 1,
|
|
141
|
+
};
|
|
142
|
+
const next = upsertMessage([existing], { ...existing, id: "msg_2" });
|
|
143
|
+
expect(next).toHaveLength(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("groupTurns: assistant berdempetan (satu turn) menjadi satu grup", () => {
|
|
147
|
+
const messages: SessionMessage[] = [
|
|
148
|
+
{ id: "u1", sessionId: "s1", role: "user", parts: [], createdAt: 1 },
|
|
149
|
+
{
|
|
150
|
+
id: "a1",
|
|
151
|
+
sessionId: "s1",
|
|
152
|
+
role: "assistant",
|
|
153
|
+
parts: [{ type: "reasoning", text: "pikir" }],
|
|
154
|
+
createdAt: 2,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "a2",
|
|
158
|
+
sessionId: "s1",
|
|
159
|
+
role: "assistant",
|
|
160
|
+
parts: [{ type: "tool", tool: "read" }],
|
|
161
|
+
createdAt: 3,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: "a3",
|
|
165
|
+
sessionId: "s1",
|
|
166
|
+
role: "assistant",
|
|
167
|
+
parts: [{ type: "text", text: "jawaban" }],
|
|
168
|
+
createdAt: 4,
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
const groups = groupTurns(messages);
|
|
172
|
+
expect(groups).toHaveLength(2);
|
|
173
|
+
expect(groups[0]).toMatchObject({ kind: "user" });
|
|
174
|
+
expect(groups[1]).toMatchObject({ kind: "assistant", id: "a1" });
|
|
175
|
+
if (groups[1]?.kind === "assistant") {
|
|
176
|
+
expect(groups[1].messages.map((m) => m.id)).toEqual(["a1", "a2", "a3"]);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("groupTurns: user memisahkan dua turn assistant", () => {
|
|
181
|
+
const mk = (id: string, role: "user" | "assistant"): SessionMessage => ({
|
|
182
|
+
id,
|
|
183
|
+
sessionId: "s1",
|
|
184
|
+
role,
|
|
185
|
+
parts: [],
|
|
186
|
+
createdAt: 1,
|
|
187
|
+
});
|
|
188
|
+
const groups = groupTurns([mk("a1", "assistant"), mk("u1", "user"), mk("a2", "assistant")]);
|
|
189
|
+
expect(groups).toHaveLength(3);
|
|
190
|
+
expect(groups[0]).toMatchObject({ kind: "assistant", id: "a1" });
|
|
191
|
+
expect(groups[1]).toMatchObject({ kind: "user" });
|
|
192
|
+
expect(groups[2]).toMatchObject({ kind: "assistant", id: "a2" });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("turnSegments: reasoning tampil terpisah sesuai urutan (ala terminal)", () => {
|
|
196
|
+
const messages: SessionMessage[] = [
|
|
197
|
+
{
|
|
198
|
+
id: "a1",
|
|
199
|
+
sessionId: "s1",
|
|
200
|
+
role: "assistant",
|
|
201
|
+
parts: [
|
|
202
|
+
{ type: "reasoning", id: "r1", text: "pikir pertama" },
|
|
203
|
+
{ type: "tool", id: "t1", tool: "read" },
|
|
204
|
+
{ type: "reasoning", id: "r2", text: "pikir kedua" },
|
|
205
|
+
{ type: "text", id: "x1", text: "jawaban" },
|
|
206
|
+
],
|
|
207
|
+
createdAt: 1,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
const segs = turnSegments(messages);
|
|
211
|
+
expect(segs.map((s) => s.kind)).toEqual(["reasoning", "tool", "reasoning", "text"]);
|
|
212
|
+
expect(segs[0]).toMatchObject({ kind: "reasoning", key: "a1:r1" });
|
|
213
|
+
expect(segs[2]).toMatchObject({ kind: "reasoning", key: "a1:r2" });
|
|
214
|
+
// Segmen teks terakhir = jawaban final.
|
|
215
|
+
expect(segs[3]).toMatchObject({ kind: "text", final: true, text: "jawaban" });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("turnSegments: antar pesan dalam satu turn tetap berurutan", () => {
|
|
219
|
+
const messages: SessionMessage[] = [
|
|
220
|
+
{
|
|
221
|
+
id: "a1",
|
|
222
|
+
sessionId: "s1",
|
|
223
|
+
role: "assistant",
|
|
224
|
+
parts: [{ type: "reasoning", id: "r1", text: "pikir A" }],
|
|
225
|
+
createdAt: 1,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "a2",
|
|
229
|
+
sessionId: "s1",
|
|
230
|
+
role: "assistant",
|
|
231
|
+
parts: [
|
|
232
|
+
{ type: "tool", id: "t1", tool: "bash" },
|
|
233
|
+
{ type: "reasoning", id: "r2", text: "pikir B" },
|
|
234
|
+
{ type: "text", id: "x1", text: "jawaban" },
|
|
235
|
+
],
|
|
236
|
+
createdAt: 2,
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
const segs = turnSegments(messages);
|
|
240
|
+
expect(segs.map((s) => s.kind)).toEqual(["reasoning", "tool", "reasoning", "text"]);
|
|
241
|
+
expect(segs.map((s) => s.key)).toEqual(["a1:r1", "a2:t1", "a2:r2", "a2:x1"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("turnSegments: part text berdempetan digabung; teks interim bukan final", () => {
|
|
245
|
+
const messages: SessionMessage[] = [
|
|
246
|
+
{
|
|
247
|
+
id: "a1",
|
|
248
|
+
sessionId: "s1",
|
|
249
|
+
role: "assistant",
|
|
250
|
+
parts: [
|
|
251
|
+
{ type: "text", id: "x1", text: "progres..." },
|
|
252
|
+
{ type: "tool", id: "t1", tool: "read" },
|
|
253
|
+
{ type: "text", id: "x2", text: "jawaban" },
|
|
254
|
+
{ type: "text", id: "x3", text: "lanjutan" },
|
|
255
|
+
],
|
|
256
|
+
createdAt: 1,
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
const segs = turnSegments(messages);
|
|
260
|
+
expect(segs.map((s) => s.kind)).toEqual(["text", "tool", "text"]);
|
|
261
|
+
expect(segs[0]).toMatchObject({ kind: "text", final: false, text: "progres..." });
|
|
262
|
+
expect(segs[2]).toMatchObject({ kind: "text", final: true, text: "jawaban\nlanjutan" });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("turnSegments: step & part lain dilewati, error jadi segmen sendiri", () => {
|
|
266
|
+
const messages: SessionMessage[] = [
|
|
267
|
+
{
|
|
268
|
+
id: "a1",
|
|
269
|
+
sessionId: "s1",
|
|
270
|
+
role: "assistant",
|
|
271
|
+
parts: [
|
|
272
|
+
{ type: "step-start" },
|
|
273
|
+
{ type: "error", id: "e1", text: "gagal" },
|
|
274
|
+
{ type: "step-finish" },
|
|
275
|
+
],
|
|
276
|
+
createdAt: 1,
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
const segs = turnSegments(messages);
|
|
280
|
+
expect(segs).toHaveLength(1);
|
|
281
|
+
expect(segs[0]).toMatchObject({ kind: "error", key: "a1:e1" });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("toolLabel: part tool dengan state.input.filePath -> nama + target", () => {
|
|
285
|
+
const part: MessagePart = {
|
|
286
|
+
type: "tool",
|
|
287
|
+
tool: "read",
|
|
288
|
+
state: { input: { filePath: "src/index.ts" } },
|
|
289
|
+
};
|
|
290
|
+
expect(toolLabel(part)).toBe("read src/index.ts");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("toolLabel: fallback key path/command/pattern", () => {
|
|
294
|
+
expect(toolLabel({ type: "tool", tool: "bash", state: { input: { command: "bun test" } } })).toBe(
|
|
295
|
+
"bash bun test",
|
|
296
|
+
);
|
|
297
|
+
expect(toolLabel({ type: "tool", tool: "glob", state: { input: { pattern: "**/*.ts" } } })).toBe(
|
|
298
|
+
"glob **/*.ts",
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("toolLabel: part file (echo @file) memakai filename", () => {
|
|
303
|
+
expect(toolLabel({ type: "file", mime: "text/plain", filename: "package.json" })).toBe(
|
|
304
|
+
"file package.json",
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("toolLabel: tanpa target -> nama tool saja", () => {
|
|
309
|
+
expect(toolLabel({ type: "tool", tool: "read" })).toBe("read");
|
|
310
|
+
// tool kosong -> fallback ke tipe part.
|
|
311
|
+
expect(toolLabel({ type: "tool" })).toBe("tool");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("hasVisibleContent: reasoning/tool/error/teks dianggap konten", () => {
|
|
315
|
+
const mk = (parts: MessagePart[]): SessionMessage[] => [
|
|
316
|
+
{ id: "a1", sessionId: "s1", role: "assistant", parts, createdAt: 1 },
|
|
317
|
+
];
|
|
318
|
+
expect(hasVisibleContent(turnSegments(mk([{ type: "reasoning", text: "pikir" }])))).toBe(true);
|
|
319
|
+
expect(hasVisibleContent(turnSegments(mk([{ type: "tool", tool: "read" }])))).toBe(true);
|
|
320
|
+
expect(hasVisibleContent(turnSegments(mk([{ type: "error", text: "gagal" }])))).toBe(true);
|
|
321
|
+
expect(hasVisibleContent(turnSegments(mk([{ type: "text", text: "jawaban" }])))).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("hasVisibleContent: step & part kosong bukan konten (indikator tetap tampil)", () => {
|
|
325
|
+
const mk = (parts: MessagePart[]): SessionMessage[] => [
|
|
326
|
+
{ id: "a1", sessionId: "s1", role: "assistant", parts, createdAt: 1 },
|
|
327
|
+
];
|
|
328
|
+
expect(hasVisibleContent(turnSegments(mk([{ type: "step-start" }])))).toBe(false);
|
|
329
|
+
expect(hasVisibleContent(turnSegments(mk([{ type: "text", text: "" }])))).toBe(false);
|
|
330
|
+
expect(hasVisibleContent(turnSegments(mk([{ type: "reasoning", text: " " }])))).toBe(false);
|
|
331
|
+
expect(hasVisibleContent(turnSegments(mk([])))).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("turnStatus: full cycle Working -> Thinking -> tool -> Writing -> null", () => {
|
|
335
|
+
const mk = (parts: MessagePart[]): SessionMessage[] => [
|
|
336
|
+
{ id: "a1", sessionId: "s1", role: "assistant", parts, createdAt: 1 },
|
|
337
|
+
];
|
|
338
|
+
// Belum ada konten apa pun.
|
|
339
|
+
expect(turnStatus(mk([{ type: "step-start" }]), true)).toBe("Working…");
|
|
340
|
+
// Reasoning terakhir masih berjalan.
|
|
341
|
+
expect(turnStatus(mk([{ type: "reasoning", text: "pikir" }]), true)).toBe("Thinking…");
|
|
342
|
+
// Tool call terakhir: label tool + target.
|
|
343
|
+
expect(
|
|
344
|
+
turnStatus(
|
|
345
|
+
mk([
|
|
346
|
+
{ type: "reasoning", text: "pikir" },
|
|
347
|
+
{ type: "tool", tool: "read", state: { input: { filePath: "package.json" } } },
|
|
348
|
+
]),
|
|
349
|
+
true,
|
|
350
|
+
),
|
|
351
|
+
).toBe("read package.json");
|
|
352
|
+
// Teks mulai diketik (belum final).
|
|
353
|
+
expect(
|
|
354
|
+
turnStatus(
|
|
355
|
+
mk([
|
|
356
|
+
{ type: "reasoning", text: "pikir" },
|
|
357
|
+
{ type: "text", text: "jawaban" },
|
|
358
|
+
]),
|
|
359
|
+
true,
|
|
360
|
+
),
|
|
361
|
+
).toBe("Writing…");
|
|
362
|
+
// Turn selesai -> maskot disembunyikan.
|
|
363
|
+
expect(turnStatus(mk([{ type: "text", text: "jawaban" }]), false)).toBeNull();
|
|
364
|
+
// Tidak ada pesan sama sekali.
|
|
365
|
+
expect(turnStatus([], true)).toBeNull();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("turnStatus: akhir turn ditandai streaming=false (pesan final menggantikan)", () => {
|
|
369
|
+
const mk = (parts: MessagePart[]): SessionMessage[] => [
|
|
370
|
+
{ id: "a1", sessionId: "s1", role: "assistant", parts, createdAt: 1 },
|
|
371
|
+
];
|
|
372
|
+
// Segmen teks terakhir ditandai final...
|
|
373
|
+
const segs = turnSegments(mk([{ type: "text", text: "jawaban" }]));
|
|
374
|
+
expect(segs[0]).toMatchObject({ final: true });
|
|
375
|
+
// ...tapi penanda selesai turn adalah streaming=false: pesan final selalu
|
|
376
|
+
// menggantikan versi streaming, sehingga kontraknya sederhana.
|
|
377
|
+
expect(turnStatus(mk([{ type: "text", text: "jawaban" }]), true)).toBe("Writing…");
|
|
378
|
+
expect(turnStatus(mk([{ type: "text", text: "jawaban" }]), false)).toBeNull();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("turnStatus: error menghentikan proses -> null", () => {
|
|
382
|
+
const mk = (parts: MessagePart[]): SessionMessage[] => [
|
|
383
|
+
{ id: "a1", sessionId: "s1", role: "assistant", parts, createdAt: 1 },
|
|
384
|
+
];
|
|
385
|
+
expect(turnStatus(mk([{ type: "error", text: "gagal" }]), true)).toBeNull();
|
|
386
|
+
});
|