@k2b/cloud 0.6.0-rc.0 → 0.7.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 +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { parseAiTodoPlan } from "./todo-contracts";
|
|
1
2
|
import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
|
|
2
3
|
import type { Usage } from "@k2b/nessi/ai";
|
|
3
|
-
import { sql } from "bun";
|
|
4
|
+
import { sql, type SQL } from "bun";
|
|
4
5
|
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
6
|
import { logger } from "../services/logging";
|
|
6
7
|
import { toPgTextArray } from "../services/postgres";
|
|
@@ -89,6 +90,7 @@ const isSearchCapabilityError = (error: unknown): boolean =>
|
|
|
89
90
|
typeof error === "object" && error !== null && "code" in error && SEARCH_CAPABILITY_ERROR_CODES.has(String(error.code));
|
|
90
91
|
|
|
91
92
|
type ConversationRow = {
|
|
93
|
+
activity?: AiConversation["activity"];
|
|
92
94
|
id: string;
|
|
93
95
|
short_id: string;
|
|
94
96
|
title: string;
|
|
@@ -98,8 +100,14 @@ type ConversationRow = {
|
|
|
98
100
|
keywords: string[] | null;
|
|
99
101
|
pinned_at: Date | string | null;
|
|
100
102
|
archived_at: Date | string | null;
|
|
103
|
+
done: boolean | null;
|
|
104
|
+
last_used_at: Date | string;
|
|
105
|
+
is_done?: boolean;
|
|
106
|
+
has_active_schedule?: boolean;
|
|
107
|
+
background_received_at?: Date | string | null;
|
|
101
108
|
last_viewed_at: Date | string | null;
|
|
102
109
|
latest_turn_status?: AiTurnStatus | null;
|
|
110
|
+
latest_browser_pending?: boolean;
|
|
103
111
|
latest_turn_error?: string | null;
|
|
104
112
|
latest_turn_completed_at?: Date | string | null;
|
|
105
113
|
enrich_fail_count: number | null;
|
|
@@ -157,6 +165,9 @@ type MessageRow = {
|
|
|
157
165
|
};
|
|
158
166
|
|
|
159
167
|
type TurnRow = {
|
|
168
|
+
deadline?: Date | string | null;
|
|
169
|
+
run_budget_ms?: number | null;
|
|
170
|
+
cancel_requested_at?: Date | string | null;
|
|
160
171
|
id: string;
|
|
161
172
|
short_id: string;
|
|
162
173
|
conversation_id: string;
|
|
@@ -360,7 +371,7 @@ const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage =>
|
|
|
360
371
|
deliveredAt: row.delivered_at ? iso(row.delivered_at) : null,
|
|
361
372
|
});
|
|
362
373
|
|
|
363
|
-
const interChatMessageSelect = sql`
|
|
374
|
+
const interChatMessageSelect = () => sql`
|
|
364
375
|
SELECT message.id, message.short_id, message.source_conversation_id,
|
|
365
376
|
source.short_id AS source_chat_id, source.title AS source_title,
|
|
366
377
|
message.source_turn_id, source_turn.short_id AS source_turn_short_id, message.source_call_id,
|
|
@@ -470,16 +481,55 @@ const parseCapabilityActionReview = (value: unknown): CapabilityActionReview | u
|
|
|
470
481
|
|
|
471
482
|
const fieldSource = (value: string | null): AiConversation["titleSource"] => (value === "auto" || value === "user" ? value : "default");
|
|
472
483
|
|
|
473
|
-
|
|
484
|
+
// Pins stay active; otherwise explicit choices win and automatic completion never hides pending work.
|
|
485
|
+
const effectiveDone = () => sql`(conversation.pinned_at IS NULL AND COALESCE(conversation.done,
|
|
486
|
+
conversation.last_used_at <= now() - interval '7 days'
|
|
487
|
+
AND NOT EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active')
|
|
488
|
+
AND NOT EXISTS (SELECT 1 FROM ai.turns active_turn
|
|
489
|
+
WHERE active_turn.conversation_id = conversation.id
|
|
490
|
+
AND NOT COALESCE(active_turn.run_config ? 'background', false)
|
|
491
|
+
AND active_turn.status IN ('queued', 'running', 'waiting_for_action'))
|
|
492
|
+
AND NOT EXISTS (SELECT 1 FROM ai.queued_messages pending WHERE pending.conversation_id = conversation.id AND pending.status IN ('pending','failed'))))`;
|
|
493
|
+
|
|
494
|
+
const browserWorkPending = () => sql`(
|
|
495
|
+
latest.status = 'waiting_for_action'
|
|
496
|
+
AND EXISTS (
|
|
497
|
+
SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
|
|
498
|
+
WHERE block->>'kind' = 'tool' AND block->>'status' = 'awaiting_client' AND block->>'frontendMode' = 'client'
|
|
499
|
+
)
|
|
500
|
+
AND NOT EXISTS (
|
|
501
|
+
SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
|
|
502
|
+
WHERE block->>'kind' = 'tool' AND (
|
|
503
|
+
block->>'status' = 'awaiting_approval'
|
|
504
|
+
OR (block->>'status' = 'awaiting_client' AND COALESCE(block->>'frontendMode', '') <> 'client')
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
)`;
|
|
508
|
+
|
|
509
|
+
const conversationRunStatus = (status: AiTurnStatus | null | undefined, browserPending = false): AiConversation["runStatus"] => {
|
|
474
510
|
if (status === "queued") return "queued";
|
|
475
511
|
if (status === "running") return "running";
|
|
476
|
-
if (status === "waiting_for_action") return "needs_attention";
|
|
512
|
+
if (status === "waiting_for_action") return browserPending ? "waiting_for_browser" : "needs_attention";
|
|
477
513
|
if (status === "failed") return "failed";
|
|
478
514
|
return "idle";
|
|
479
515
|
};
|
|
480
516
|
|
|
517
|
+
// Bun SQL may return historical JSON payloads as string-encoded JSON.
|
|
518
|
+
const todoMessageJson = () => sql`(CASE WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb ELSE message END)`;
|
|
519
|
+
const todoMetaJson = () => sql`(CASE WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb ELSE meta END)`;
|
|
520
|
+
|
|
521
|
+
// A scheduled result arriving during an interactive turn becomes context on the next turn.
|
|
522
|
+
const visibleToTurn = (turnId?: string) => sql`(
|
|
523
|
+
${turnId ?? null}::text IS NULL OR NOT COALESCE(meta ? 'scheduledTask', false) OR seq < ALL (
|
|
524
|
+
SELECT input_message.seq FROM ai.messages input_message
|
|
525
|
+
WHERE input_message.loop_id = ${turnId ?? null} AND input_message.role = 'user'
|
|
526
|
+
)
|
|
527
|
+
)`;
|
|
528
|
+
|
|
481
529
|
const rowToConversation = (row: ConversationRow): AiConversation => ({
|
|
530
|
+
...(row.activity ? {activity:row.activity} : {}),
|
|
482
531
|
id: row.id,
|
|
532
|
+
hasActiveSchedule: Boolean(row.has_active_schedule),
|
|
483
533
|
shortId: row.short_id,
|
|
484
534
|
title: row.title,
|
|
485
535
|
titleSource: fieldSource(row.title_source),
|
|
@@ -488,9 +538,17 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
|
|
|
488
538
|
keywords: row.keywords ?? [],
|
|
489
539
|
pinnedAt: row.pinned_at ? iso(row.pinned_at) : null,
|
|
490
540
|
archivedAt: row.archived_at ? iso(row.archived_at) : null,
|
|
491
|
-
|
|
541
|
+
done: row.done,
|
|
542
|
+
isDone: row.pinned_at ? false : row.is_done ?? row.done ?? (
|
|
543
|
+
new Date(row.last_used_at).getTime() <= Date.now() - 7 * 86400000
|
|
544
|
+
&& !row.has_active_schedule
|
|
545
|
+
&& !["queued", "running", "waiting_for_action"].includes(row.latest_turn_status ?? "")
|
|
546
|
+
),
|
|
547
|
+
lastUsedAt: iso(row.last_used_at),
|
|
548
|
+
runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
|
|
492
549
|
runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
|
|
493
550
|
unreadCompletion:
|
|
551
|
+
Boolean(row.background_received_at && (!row.last_viewed_at || new Date(row.background_received_at).getTime() > new Date(row.last_viewed_at).getTime())) ||
|
|
494
552
|
row.latest_turn_status === "completed" &&
|
|
495
553
|
Boolean(row.latest_turn_completed_at) &&
|
|
496
554
|
(!row.last_viewed_at || new Date(row.latest_turn_completed_at!).getTime() > new Date(row.last_viewed_at).getTime()),
|
|
@@ -566,6 +624,9 @@ const rowToMessage = (row: MessageRow): AiStoredMessage => {
|
|
|
566
624
|
};
|
|
567
625
|
|
|
568
626
|
const rowToTurn = (row: TurnRow): AiTurn => ({
|
|
627
|
+
deadline: row.deadline ? iso(row.deadline) : null,
|
|
628
|
+
runBudgetMs: row.run_budget_ms ?? null,
|
|
629
|
+
cancelRequestedAt: row.cancel_requested_at ? iso(row.cancel_requested_at) : null,
|
|
569
630
|
id: row.id,
|
|
570
631
|
shortId: row.short_id,
|
|
571
632
|
conversationId: row.conversation_id,
|
|
@@ -591,7 +652,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
|
|
|
591
652
|
conversationId: row.conversation_id,
|
|
592
653
|
callId: row.call_id,
|
|
593
654
|
name: row.tool_name,
|
|
594
|
-
args:
|
|
655
|
+
args: row.args,
|
|
595
656
|
mode: row.frontend_mode ?? "client",
|
|
596
657
|
}
|
|
597
658
|
: {
|
|
@@ -600,7 +661,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
|
|
|
600
661
|
conversationId: row.conversation_id,
|
|
601
662
|
callId: row.call_id,
|
|
602
663
|
name: row.tool_name,
|
|
603
|
-
args:
|
|
664
|
+
args: row.args,
|
|
604
665
|
message: row.message ?? undefined,
|
|
605
666
|
review: parseCapabilityActionReview(row.review),
|
|
606
667
|
allowAlways: row.allow_always,
|
|
@@ -613,7 +674,7 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
|
|
|
613
674
|
kind: row.kind,
|
|
614
675
|
status: row.status,
|
|
615
676
|
name: row.tool_name,
|
|
616
|
-
args:
|
|
677
|
+
args: row.args,
|
|
617
678
|
message: row.message ?? undefined,
|
|
618
679
|
review: parseCapabilityActionReview(row.review),
|
|
619
680
|
approvalScope: row.approval_scope,
|
|
@@ -649,14 +710,17 @@ const loadConversationSummary = async (input: {
|
|
|
649
710
|
const rows = await sql<ConversationRow[]>`
|
|
650
711
|
SELECT
|
|
651
712
|
conversation.*,
|
|
713
|
+
${effectiveDone()} AS is_done,
|
|
714
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
652
715
|
latest.status AS latest_turn_status,
|
|
716
|
+
${browserWorkPending()} AS latest_browser_pending,
|
|
653
717
|
latest.error AS latest_turn_error,
|
|
654
718
|
latest.completed_at AS latest_turn_completed_at
|
|
655
719
|
FROM ai.conversations conversation
|
|
656
720
|
LEFT JOIN LATERAL (
|
|
657
|
-
SELECT status, error, completed_at
|
|
721
|
+
SELECT status, error, completed_at, live_blocks
|
|
658
722
|
FROM ai.turns
|
|
659
|
-
WHERE conversation_id = conversation.id
|
|
723
|
+
WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
|
|
660
724
|
ORDER BY created_at DESC, id DESC
|
|
661
725
|
LIMIT 1
|
|
662
726
|
) latest ON TRUE
|
|
@@ -666,7 +730,15 @@ const loadConversationSummary = async (input: {
|
|
|
666
730
|
AND (${Boolean(input.archived)}::boolean = (conversation.archived_at IS NOT NULL))
|
|
667
731
|
LIMIT 1
|
|
668
732
|
`;
|
|
669
|
-
|
|
733
|
+
if (!rows[0]) return null;
|
|
734
|
+
const plans = await sql<{ seq: number; plan: unknown }[]>`
|
|
735
|
+
SELECT seq, CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
|
|
736
|
+
FROM ai.messages WHERE conversation_id = ${rows[0].id}
|
|
737
|
+
AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
|
|
738
|
+
ORDER BY seq DESC, id DESC LIMIT 1
|
|
739
|
+
`;
|
|
740
|
+
const plan = parseAiTodoPlan(plans[0]?.plan);
|
|
741
|
+
return { ...rowToConversation(rows[0]), ...(plan ? { todoPlan: { seq: Number(plans[0]!.seq), todos: plan.todos } } : {}) };
|
|
670
742
|
};
|
|
671
743
|
|
|
672
744
|
const firstText = (message: Message): string => {
|
|
@@ -745,14 +817,14 @@ const insertMessageLocked = async (
|
|
|
745
817
|
${seq},
|
|
746
818
|
${input.kind ?? "message"},
|
|
747
819
|
${input.message.role},
|
|
748
|
-
${JSON.stringify(input.message)}::jsonb,
|
|
820
|
+
${JSON.stringify(input.message)}::text::jsonb,
|
|
749
821
|
${messageSearchText(input.message)},
|
|
750
822
|
${input.loopId ?? null},
|
|
751
823
|
${input.modelProfileId ?? null},
|
|
752
824
|
${providerModel},
|
|
753
|
-
${usage ? JSON.stringify(usage) : null}::jsonb,
|
|
825
|
+
${usage ? JSON.stringify(usage) : null}::text::jsonb,
|
|
754
826
|
${stopReason},
|
|
755
|
-
${input.meta ? JSON.stringify(input.meta) : null}::jsonb
|
|
827
|
+
${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
|
|
756
828
|
)
|
|
757
829
|
RETURNING *
|
|
758
830
|
`,
|
|
@@ -776,6 +848,7 @@ const insertMessageLocked = async (
|
|
|
776
848
|
|
|
777
849
|
/** Append a turn-owned (assistant/tool_result/summary) message, guarded by lease ownership in one statement. */
|
|
778
850
|
const appendTurnOwnedMessage = async (input: {
|
|
851
|
+
background?: boolean;
|
|
779
852
|
conversationId: string;
|
|
780
853
|
turnId: string;
|
|
781
854
|
leaseOwner: string;
|
|
@@ -785,12 +858,14 @@ const appendTurnOwnedMessage = async (input: {
|
|
|
785
858
|
loopId: string | null;
|
|
786
859
|
modelProfileId?: string | null;
|
|
787
860
|
meta?: AiStoredMessage["meta"];
|
|
788
|
-
}): Promise<
|
|
861
|
+
}): Promise<AiStoredMessage | null> => {
|
|
862
|
+
return sql.begin(async tx => {
|
|
863
|
+
if (!input.background) await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid FOR UPDATE`;
|
|
789
864
|
const { usage, providerModel, stopReason } = messageColumns(input.message);
|
|
790
|
-
const rows = await
|
|
865
|
+
const rows = await withAiShortIdForDb(tx,
|
|
791
866
|
"idx_ai_messages_conversation_short_id",
|
|
792
|
-
(shortId) =>
|
|
793
|
-
INSERT INTO ai.messages (
|
|
867
|
+
(attempt, shortId) => attempt<MessageRow[]>`
|
|
868
|
+
INSERT INTO ${sql(input.background ? "ai.task_messages" : "ai.messages")} (
|
|
794
869
|
short_id,
|
|
795
870
|
conversation_id,
|
|
796
871
|
seq,
|
|
@@ -808,18 +883,18 @@ const appendTurnOwnedMessage = async (input: {
|
|
|
808
883
|
${shortId},
|
|
809
884
|
${input.conversationId},
|
|
810
885
|
CASE
|
|
811
|
-
WHEN ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
|
|
812
|
-
ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0)
|
|
886
|
+
WHEN NOT ${Boolean(input.background)} AND ${input.kind === 'summary'} AND ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
|
|
887
|
+
ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ${sql(input.background ? "ai.task_messages" : "ai.messages")} WHERE conversation_id = ${input.conversationId} AND seq > 0 AND (NOT ${Boolean(input.background)} OR loop_id = ${input.turnId}))
|
|
813
888
|
END,
|
|
814
889
|
${input.kind ?? "message"},
|
|
815
890
|
${input.message.role},
|
|
816
|
-
${JSON.stringify(input.message)}::jsonb,
|
|
891
|
+
${JSON.stringify(input.message)}::text::jsonb,
|
|
817
892
|
${input.loopId},
|
|
818
893
|
${input.modelProfileId ?? null},
|
|
819
894
|
${providerModel},
|
|
820
|
-
${usage ? JSON.stringify(usage) : null}::jsonb,
|
|
895
|
+
${usage ? JSON.stringify(usage) : null}::text::jsonb,
|
|
821
896
|
${stopReason},
|
|
822
|
-
${input.meta ? JSON.stringify(input.meta) : null}::jsonb
|
|
897
|
+
${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
|
|
823
898
|
WHERE EXISTS (
|
|
824
899
|
SELECT 1
|
|
825
900
|
FROM ai.turns
|
|
@@ -828,14 +903,15 @@ const appendTurnOwnedMessage = async (input: {
|
|
|
828
903
|
AND status IN ('running', 'waiting_for_action')
|
|
829
904
|
AND lease_owner = ${input.leaseOwner}
|
|
830
905
|
)
|
|
831
|
-
RETURNING
|
|
906
|
+
RETURNING *
|
|
832
907
|
`,
|
|
833
908
|
);
|
|
834
909
|
if (rows[0]) {
|
|
835
|
-
await
|
|
836
|
-
return
|
|
910
|
+
if (!input.background) await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
|
|
911
|
+
return rowToMessage(rows[0]);
|
|
837
912
|
}
|
|
838
|
-
return
|
|
913
|
+
return null;
|
|
914
|
+
});
|
|
839
915
|
};
|
|
840
916
|
|
|
841
917
|
const toolMessageMeta = (
|
|
@@ -857,6 +933,69 @@ const toolMessageMeta = (
|
|
|
857
933
|
return Object.keys(toolPresentations).length > 0 ? { toolPresentations } : null;
|
|
858
934
|
};
|
|
859
935
|
|
|
936
|
+
export const saveAiDraftInTransaction = async (
|
|
937
|
+
tx: SQL,
|
|
938
|
+
input: Parameters<AiConversationService["saveDraft"]>[0],
|
|
939
|
+
): ReturnType<AiConversationService["saveDraft"]> => {
|
|
940
|
+
const [conversation] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]>`
|
|
941
|
+
SELECT draft_content, draft_revision, draft_updated_at
|
|
942
|
+
FROM ai.conversations
|
|
943
|
+
WHERE id = ${input.conversationId}::uuid
|
|
944
|
+
AND created_by_user_id = ${input.ownerUserId}::uuid
|
|
945
|
+
AND archived_at IS NULL
|
|
946
|
+
FOR UPDATE
|
|
947
|
+
`;
|
|
948
|
+
if (!conversation) return { ok: false as const, reason: "not_found" as const };
|
|
949
|
+
if (Number(conversation.draft_revision) !== input.expectedRevision) {
|
|
950
|
+
return { ok: false as const, reason: "conflict" as const };
|
|
951
|
+
}
|
|
952
|
+
const [sameDraft] = await tx<{ same: boolean }[]>`
|
|
953
|
+
SELECT ${JSON.stringify(input.content)}::text::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::text::jsonb AS same
|
|
954
|
+
`;
|
|
955
|
+
if (sameDraft?.same) {
|
|
956
|
+
return {
|
|
957
|
+
ok: true as const,
|
|
958
|
+
draft: {
|
|
959
|
+
content: input.content,
|
|
960
|
+
revision: Number(conversation.draft_revision),
|
|
961
|
+
updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
for (const part of input.content) {
|
|
967
|
+
if (part.type !== "file") continue;
|
|
968
|
+
const [file] = await tx<{ exists: boolean }[]>`
|
|
969
|
+
SELECT EXISTS (
|
|
970
|
+
SELECT 1 FROM ai.files
|
|
971
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
972
|
+
AND path = ${part.path}
|
|
973
|
+
AND media_type = ${part.mediaType}
|
|
974
|
+
AND size = ${part.size}
|
|
975
|
+
AND version = ${part.version}
|
|
976
|
+
) AS exists
|
|
977
|
+
`;
|
|
978
|
+
if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
|
|
982
|
+
UPDATE ai.conversations
|
|
983
|
+
SET draft_content = ${JSON.stringify(input.content)}::text::jsonb,
|
|
984
|
+
draft_revision = draft_revision + 1,
|
|
985
|
+
draft_updated_at = now()
|
|
986
|
+
WHERE id = ${input.conversationId}::uuid
|
|
987
|
+
RETURNING draft_content, draft_revision, draft_updated_at
|
|
988
|
+
`;
|
|
989
|
+
return {
|
|
990
|
+
ok: true as const,
|
|
991
|
+
draft: {
|
|
992
|
+
content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
|
|
993
|
+
revision: Number(saved!.draft_revision),
|
|
994
|
+
updatedAt: iso(saved!.draft_updated_at),
|
|
995
|
+
},
|
|
996
|
+
};
|
|
997
|
+
};
|
|
998
|
+
|
|
860
999
|
export const aiConversations: AiConversationService = {
|
|
861
1000
|
createConversation: async (input) => {
|
|
862
1001
|
const rows = await withAiShortId(
|
|
@@ -880,7 +1019,7 @@ export const aiConversations: AiConversationService = {
|
|
|
880
1019
|
${input.title?.trim() || "New chat"},
|
|
881
1020
|
${input.description?.trim() ?? ""},
|
|
882
1021
|
${input.projectId ?? null}::uuid,
|
|
883
|
-
${JSON.stringify(input.draft ?? [])}::jsonb,
|
|
1022
|
+
${JSON.stringify(input.draft ?? [])}::text::jsonb,
|
|
884
1023
|
${input.draft?.length ? 1 : 0},
|
|
885
1024
|
${toPgTextArray(input.preloadTools ?? [])}::text[],
|
|
886
1025
|
${input.draft?.length ? new Date() : null},
|
|
@@ -920,11 +1059,12 @@ export const aiConversations: AiConversationService = {
|
|
|
920
1059
|
await tx`
|
|
921
1060
|
INSERT INTO ai.messages (
|
|
922
1061
|
short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
|
|
923
|
-
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
|
|
1062
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta
|
|
924
1063
|
)
|
|
925
1064
|
SELECT
|
|
926
1065
|
short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
|
|
927
|
-
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
|
|
1066
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason,
|
|
1067
|
+
CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
|
|
928
1068
|
FROM ai.messages
|
|
929
1069
|
WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
930
1070
|
AND compacted_at IS NULL
|
|
@@ -932,8 +1072,8 @@ export const aiConversations: AiConversationService = {
|
|
|
932
1072
|
ORDER BY seq ASC
|
|
933
1073
|
`;
|
|
934
1074
|
await tx`
|
|
935
|
-
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
|
|
936
|
-
SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, updated_at
|
|
1075
|
+
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
|
|
1076
|
+
SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
|
|
937
1077
|
FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
938
1078
|
`;
|
|
939
1079
|
return rowToConversation(target);
|
|
@@ -950,23 +1090,29 @@ export const aiConversations: AiConversationService = {
|
|
|
950
1090
|
const rows = await withConversationSearchBackend(query, async (backend) => {
|
|
951
1091
|
const order = query
|
|
952
1092
|
? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
|
|
953
|
-
:
|
|
1093
|
+
: input.done === true
|
|
1094
|
+
? sql`conversation.last_used_at DESC, conversation.id DESC`
|
|
1095
|
+
: sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
|
|
954
1096
|
return sql<ConversationRow[]>`
|
|
955
1097
|
SELECT
|
|
956
1098
|
conversation.*,
|
|
1099
|
+
${effectiveDone()} AS is_done,
|
|
1100
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
957
1101
|
latest.status AS latest_turn_status,
|
|
1102
|
+
${browserWorkPending()} AS latest_browser_pending,
|
|
958
1103
|
latest.error AS latest_turn_error,
|
|
959
1104
|
latest.completed_at AS latest_turn_completed_at
|
|
960
1105
|
FROM ai.conversations conversation
|
|
961
1106
|
LEFT JOIN LATERAL (
|
|
962
|
-
SELECT status, error, completed_at
|
|
1107
|
+
SELECT status, error, completed_at, live_blocks
|
|
963
1108
|
FROM ai.turns
|
|
964
|
-
WHERE conversation_id = conversation.id
|
|
1109
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
965
1110
|
ORDER BY created_at DESC, id DESC
|
|
966
1111
|
LIMIT 1
|
|
967
1112
|
) latest ON TRUE
|
|
968
1113
|
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
969
1114
|
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1115
|
+
AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
|
|
970
1116
|
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
971
1117
|
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
972
1118
|
AND (${refs.length === 0}::boolean OR NOT EXISTS (
|
|
@@ -993,10 +1139,12 @@ export const aiConversations: AiConversationService = {
|
|
|
993
1139
|
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
994
1140
|
))
|
|
995
1141
|
AND (${status}::text IS NULL
|
|
996
|
-
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
997
|
-
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1142
|
+
OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
|
|
1143
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
|
|
998
1144
|
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
999
|
-
OR (${status} = 'unread' AND
|
|
1145
|
+
OR (${status} = 'unread' AND (
|
|
1146
|
+
(latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
|
|
1147
|
+
OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
|
|
1000
1148
|
ORDER BY ${order}
|
|
1001
1149
|
LIMIT ${limit}
|
|
1002
1150
|
`;
|
|
@@ -1005,35 +1153,33 @@ export const aiConversations: AiConversationService = {
|
|
|
1005
1153
|
},
|
|
1006
1154
|
|
|
1007
1155
|
listSidebarConversations: async (input) => {
|
|
1008
|
-
const unassignedLimit = Math.min(Math.max(input.unassignedLimit ?? 15, 1), 50);
|
|
1009
|
-
const perProjectLimit = Math.min(Math.max(input.perProjectLimit ?? 10, 1), 50);
|
|
1010
1156
|
const rows = await sql<ConversationRow[]>`
|
|
1011
|
-
|
|
1012
|
-
SELECT
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
latest.
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
WHERE
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
WHERE
|
|
1035
|
-
|
|
1036
|
-
ORDER BY pinned_at DESC NULLS LAST,
|
|
1157
|
+
SELECT conversation.*, ${effectiveDone()} AS is_done,
|
|
1158
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
1159
|
+
latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
|
|
1160
|
+
latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
|
|
1161
|
+
jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
|
|
1162
|
+
'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
|
|
1163
|
+
FROM ai.conversations conversation
|
|
1164
|
+
LEFT JOIN LATERAL (
|
|
1165
|
+
SELECT status, error, completed_at, live_blocks FROM ai.turns
|
|
1166
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
|
|
1167
|
+
) latest ON TRUE
|
|
1168
|
+
LEFT JOIN LATERAL (
|
|
1169
|
+
SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
|
|
1170
|
+
FROM ai.messages WHERE conversation_id = conversation.id
|
|
1171
|
+
AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
|
|
1172
|
+
ORDER BY seq DESC, id DESC LIMIT 1
|
|
1173
|
+
) working ON TRUE
|
|
1174
|
+
LEFT JOIN LATERAL (
|
|
1175
|
+
SELECT count(*) FILTER (WHERE task->>'status' = 'completed')::int AS completed,
|
|
1176
|
+
count(*) FILTER (WHERE task->>'status' <> 'cancelled')::int AS total,
|
|
1177
|
+
max(task->>'content') FILTER (WHERE task->>'status' = 'in_progress') AS step
|
|
1178
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(working.plan->'todos') = 'array' THEN working.plan->'todos' ELSE '[]'::jsonb END) task
|
|
1179
|
+
) progress ON TRUE
|
|
1180
|
+
WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
|
|
1181
|
+
AND conversation.archived_at IS NULL AND NOT ${effectiveDone()}
|
|
1182
|
+
ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.last_used_at DESC, conversation.id
|
|
1037
1183
|
`;
|
|
1038
1184
|
return rows.map(rowToConversation);
|
|
1039
1185
|
},
|
|
@@ -1047,23 +1193,29 @@ export const aiConversations: AiConversationService = {
|
|
|
1047
1193
|
const rows = await withConversationSearchBackend(query, async (backend) => {
|
|
1048
1194
|
const order = query
|
|
1049
1195
|
? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
|
|
1050
|
-
:
|
|
1196
|
+
: input.done === true
|
|
1197
|
+
? sql`conversation.last_used_at DESC, conversation.id DESC`
|
|
1198
|
+
: sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
|
|
1051
1199
|
return sql<ConversationRow[]>`
|
|
1052
1200
|
SELECT
|
|
1053
1201
|
conversation.*,
|
|
1202
|
+
${effectiveDone()} AS is_done,
|
|
1203
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
1054
1204
|
latest.status AS latest_turn_status,
|
|
1205
|
+
${browserWorkPending()} AS latest_browser_pending,
|
|
1055
1206
|
latest.error AS latest_turn_error,
|
|
1056
1207
|
latest.completed_at AS latest_turn_completed_at
|
|
1057
1208
|
FROM ai.conversations conversation
|
|
1058
1209
|
LEFT JOIN LATERAL (
|
|
1059
|
-
SELECT status, error, completed_at
|
|
1210
|
+
SELECT status, error, completed_at, live_blocks
|
|
1060
1211
|
FROM ai.turns
|
|
1061
|
-
WHERE conversation_id = conversation.id
|
|
1212
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1062
1213
|
ORDER BY created_at DESC, id DESC
|
|
1063
1214
|
LIMIT 1
|
|
1064
1215
|
) latest ON TRUE
|
|
1065
1216
|
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
1066
1217
|
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1218
|
+
AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
|
|
1067
1219
|
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
1068
1220
|
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
1069
1221
|
AND (${pattern}::text IS NULL
|
|
@@ -1079,10 +1231,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1079
1231
|
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
1080
1232
|
))
|
|
1081
1233
|
AND (${status}::text IS NULL
|
|
1082
|
-
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
1083
|
-
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1234
|
+
OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
|
|
1235
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
|
|
1084
1236
|
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
1085
|
-
OR (${status} = 'unread' AND
|
|
1237
|
+
OR (${status} = 'unread' AND (
|
|
1238
|
+
(latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
|
|
1239
|
+
OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
|
|
1086
1240
|
ORDER BY ${order}
|
|
1087
1241
|
LIMIT ${perPage}
|
|
1088
1242
|
OFFSET ${offset}
|
|
@@ -1092,14 +1246,15 @@ export const aiConversations: AiConversationService = {
|
|
|
1092
1246
|
SELECT COUNT(*) AS total
|
|
1093
1247
|
FROM ai.conversations conversation
|
|
1094
1248
|
LEFT JOIN LATERAL (
|
|
1095
|
-
SELECT status, completed_at
|
|
1249
|
+
SELECT status, completed_at, live_blocks
|
|
1096
1250
|
FROM ai.turns
|
|
1097
|
-
WHERE conversation_id = conversation.id
|
|
1251
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1098
1252
|
ORDER BY created_at DESC, id DESC
|
|
1099
1253
|
LIMIT 1
|
|
1100
1254
|
) latest ON TRUE
|
|
1101
1255
|
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
1102
1256
|
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1257
|
+
AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
|
|
1103
1258
|
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
1104
1259
|
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
1105
1260
|
AND (${pattern}::text IS NULL
|
|
@@ -1115,10 +1270,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1115
1270
|
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
1116
1271
|
))
|
|
1117
1272
|
AND (${status}::text IS NULL
|
|
1118
|
-
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
1119
|
-
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1273
|
+
OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
|
|
1274
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
|
|
1120
1275
|
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
1121
|
-
OR (${status} = 'unread' AND
|
|
1276
|
+
OR (${status} = 'unread' AND (
|
|
1277
|
+
(latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
|
|
1278
|
+
OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
|
|
1122
1279
|
`;
|
|
1123
1280
|
const total = Number(countRows[0]?.total ?? 0);
|
|
1124
1281
|
return {
|
|
@@ -1138,68 +1295,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1138
1295
|
return loadConversationSummary(input);
|
|
1139
1296
|
},
|
|
1140
1297
|
|
|
1141
|
-
saveDraft: async (input) =>
|
|
1142
|
-
sql.begin(async (tx) => {
|
|
1143
|
-
const [conversation] = await tx<
|
|
1144
|
-
{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]
|
|
1145
|
-
>`
|
|
1146
|
-
SELECT draft_content, draft_revision, draft_updated_at
|
|
1147
|
-
FROM ai.conversations
|
|
1148
|
-
WHERE id = ${input.conversationId}::uuid
|
|
1149
|
-
AND created_by_user_id = ${input.ownerUserId}::uuid
|
|
1150
|
-
AND archived_at IS NULL
|
|
1151
|
-
FOR UPDATE
|
|
1152
|
-
`;
|
|
1153
|
-
if (!conversation) return { ok: false as const, reason: "not_found" as const };
|
|
1154
|
-
if (Number(conversation.draft_revision) !== input.expectedRevision) {
|
|
1155
|
-
return { ok: false as const, reason: "conflict" as const };
|
|
1156
|
-
}
|
|
1157
|
-
const [sameDraft] = await tx<{ same: boolean }[]>`
|
|
1158
|
-
SELECT ${JSON.stringify(input.content)}::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::jsonb AS same
|
|
1159
|
-
`;
|
|
1160
|
-
if (sameDraft?.same) {
|
|
1161
|
-
return {
|
|
1162
|
-
ok: true as const,
|
|
1163
|
-
draft: {
|
|
1164
|
-
content: input.content,
|
|
1165
|
-
revision: Number(conversation.draft_revision),
|
|
1166
|
-
updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
|
|
1167
|
-
},
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
for (const part of input.content) {
|
|
1172
|
-
if (part.type !== "file") continue;
|
|
1173
|
-
const [file] = await tx<{ exists: boolean }[]>`
|
|
1174
|
-
SELECT EXISTS (
|
|
1175
|
-
SELECT 1 FROM ai.files
|
|
1176
|
-
WHERE conversation_id = ${input.conversationId}::uuid
|
|
1177
|
-
AND path = ${part.path}
|
|
1178
|
-
AND media_type = ${part.mediaType}
|
|
1179
|
-
AND size = ${part.size}
|
|
1180
|
-
AND version = ${part.version}
|
|
1181
|
-
) AS exists
|
|
1182
|
-
`;
|
|
1183
|
-
if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
|
|
1187
|
-
UPDATE ai.conversations
|
|
1188
|
-
SET draft_content = ${JSON.stringify(input.content)}::jsonb,
|
|
1189
|
-
draft_revision = draft_revision + 1,
|
|
1190
|
-
draft_updated_at = now()
|
|
1191
|
-
WHERE id = ${input.conversationId}::uuid
|
|
1192
|
-
RETURNING draft_content, draft_revision, draft_updated_at
|
|
1193
|
-
`;
|
|
1194
|
-
return {
|
|
1195
|
-
ok: true as const,
|
|
1196
|
-
draft: {
|
|
1197
|
-
content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
|
|
1198
|
-
revision: Number(saved!.draft_revision),
|
|
1199
|
-
updatedAt: iso(saved!.draft_updated_at),
|
|
1200
|
-
},
|
|
1201
|
-
};
|
|
1202
|
-
}),
|
|
1298
|
+
saveDraft: async (input) => sql.begin((tx) => saveAiDraftInTransaction(tx, input)),
|
|
1203
1299
|
|
|
1204
1300
|
getLoadedTools: async (input) => {
|
|
1205
1301
|
const rows = await sql<{ loaded_tools: string[] | null }[]>`
|
|
@@ -1420,7 +1516,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1420
1516
|
},
|
|
1421
1517
|
|
|
1422
1518
|
createInterChatMessage: async (input) => {
|
|
1423
|
-
const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect}
|
|
1519
|
+
const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect()}
|
|
1424
1520
|
WHERE message.idempotency_key = ${input.idempotencyKey}
|
|
1425
1521
|
AND message.source_conversation_id = ${input.sourceConversationId}::uuid
|
|
1426
1522
|
AND message.actor_user_id = ${input.actorUserId}::uuid
|
|
@@ -1465,21 +1561,22 @@ export const aiConversations: AiConversationService = {
|
|
|
1465
1561
|
RETURNING id
|
|
1466
1562
|
`,
|
|
1467
1563
|
);
|
|
1468
|
-
const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
|
|
1564
|
+
const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
|
|
1469
1565
|
return { ok: true, message: rowToInterChatMessage(rows[0]!) };
|
|
1470
1566
|
},
|
|
1471
1567
|
|
|
1472
1568
|
listPendingInterChatMessages: async (input = {}) => {
|
|
1473
1569
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 100);
|
|
1474
1570
|
const rows = await sql<InterChatMessageRow[]>`
|
|
1475
|
-
${interChatMessageSelect}
|
|
1571
|
+
${interChatMessageSelect()}
|
|
1476
1572
|
WHERE message.status = 'pending'
|
|
1477
1573
|
AND (${input.targetConversationId ?? null}::uuid IS NULL OR message.target_conversation_id = ${input.targetConversationId ?? null}::uuid)
|
|
1478
1574
|
AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR NOT EXISTS (
|
|
1479
1575
|
SELECT 1
|
|
1480
1576
|
FROM ai.turns active_turn
|
|
1481
1577
|
WHERE active_turn.conversation_id = message.target_conversation_id
|
|
1482
|
-
AND active_turn.
|
|
1578
|
+
AND NOT COALESCE(active_turn.run_config ? 'background', false)
|
|
1579
|
+
AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
|
|
1483
1580
|
))
|
|
1484
1581
|
AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR message.id = (
|
|
1485
1582
|
SELECT oldest.id
|
|
@@ -1508,7 +1605,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1508
1605
|
deliverInterChatMessage: async (input) => {
|
|
1509
1606
|
const delivered = await sql.begin(async (tx) => {
|
|
1510
1607
|
const rows = await tx<InterChatMessageRow[]>`
|
|
1511
|
-
${interChatMessageSelect}
|
|
1608
|
+
${interChatMessageSelect()}
|
|
1512
1609
|
WHERE message.id = ${input.messageId}::uuid
|
|
1513
1610
|
FOR UPDATE OF message
|
|
1514
1611
|
`;
|
|
@@ -1534,6 +1631,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1534
1631
|
const [active] = await tx<{ id: string }[]>`
|
|
1535
1632
|
SELECT id FROM ai.turns
|
|
1536
1633
|
WHERE conversation_id = ${message.target_conversation_id}::uuid
|
|
1634
|
+
AND NOT COALESCE(run_config ? 'background', false)
|
|
1537
1635
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1538
1636
|
LIMIT 1
|
|
1539
1637
|
`;
|
|
@@ -1548,6 +1646,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1548
1646
|
RETURNING *
|
|
1549
1647
|
`,
|
|
1550
1648
|
);
|
|
1649
|
+
await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${message.target_conversation_id}::uuid`;
|
|
1551
1650
|
const turn = rowToTurn(turnRows[0]!);
|
|
1552
1651
|
const targetMeta: NonNullable<AiStoredMessage["meta"]> = {
|
|
1553
1652
|
agentMessage: {
|
|
@@ -1579,7 +1678,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1579
1678
|
SET status = 'delivered', target_turn_id = ${turn.id}, target_message_id = ${messageRow.id}, delivered_at = now()
|
|
1580
1679
|
WHERE id = ${message.id}::uuid
|
|
1581
1680
|
`;
|
|
1582
|
-
const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${message.id}::uuid LIMIT 1`;
|
|
1681
|
+
const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${message.id}::uuid LIMIT 1`;
|
|
1583
1682
|
return { delivered: true as const, message: rowToInterChatMessage(updated[0]!), turn };
|
|
1584
1683
|
});
|
|
1585
1684
|
return delivered;
|
|
@@ -1624,12 +1723,16 @@ export const aiConversations: AiConversationService = {
|
|
|
1624
1723
|
`;
|
|
1625
1724
|
if (!conversations[0]) return { ok: false as const, reason: "not_found" as const };
|
|
1626
1725
|
|
|
1726
|
+
if (input.onlyUnassigned && conversations[0].project_id) return { ok: false as const, reason: "already_assigned" as const };
|
|
1727
|
+
|
|
1627
1728
|
const [activity] = await tx<{ exists: boolean }[]>`
|
|
1628
1729
|
SELECT EXISTS (
|
|
1629
1730
|
SELECT 1 FROM ai.turns
|
|
1630
1731
|
WHERE conversation_id = ${input.conversationId}::uuid
|
|
1631
1732
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1632
|
-
)
|
|
1733
|
+
) OR (${Boolean(input.onlyUnassigned)}::boolean AND EXISTS (
|
|
1734
|
+
SELECT 1 FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed')
|
|
1735
|
+
)) AS exists
|
|
1633
1736
|
`;
|
|
1634
1737
|
if (activity?.exists) return { ok: false as const, reason: "active_turn" as const };
|
|
1635
1738
|
|
|
@@ -1659,6 +1762,34 @@ export const aiConversations: AiConversationService = {
|
|
|
1659
1762
|
return rows[0] ? loadConversationSummary(input) : null;
|
|
1660
1763
|
},
|
|
1661
1764
|
|
|
1765
|
+
setConversationDone: async (input) => {
|
|
1766
|
+
const result = await sql.begin(async (tx) => {
|
|
1767
|
+
// Same lock as turn submission: completion cannot race a new message.
|
|
1768
|
+
const [conversation] = await tx<{ id: string }[]>`
|
|
1769
|
+
SELECT id FROM ai.conversations
|
|
1770
|
+
WHERE id = ${input.conversationId}::uuid AND created_by_user_id = ${input.ownerUserId}::uuid
|
|
1771
|
+
AND archived_at IS NULL FOR UPDATE
|
|
1772
|
+
`;
|
|
1773
|
+
if (!conversation) return { ok: false as const, reason: "not_found" as const };
|
|
1774
|
+
if (input.done) {
|
|
1775
|
+
const [active] = await tx<{ id: string }[]>`
|
|
1776
|
+
SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid
|
|
1777
|
+
AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued', 'running', 'waiting_for_action') LIMIT 1
|
|
1778
|
+
`;
|
|
1779
|
+
const [pending] = await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
|
|
1780
|
+
if (active || pending) return { ok: false as const, reason: "active_turn" as const };
|
|
1781
|
+
}
|
|
1782
|
+
await tx`UPDATE ai.conversations
|
|
1783
|
+
SET done = ${input.done}::boolean,
|
|
1784
|
+
last_used_at = CASE WHEN ${input.done}::boolean IS FALSE THEN now() ELSE last_used_at END
|
|
1785
|
+
WHERE id = ${input.conversationId}::uuid`;
|
|
1786
|
+
return { ok: true as const };
|
|
1787
|
+
});
|
|
1788
|
+
if (!result.ok) return result;
|
|
1789
|
+
const conversation = await loadConversationSummary(input);
|
|
1790
|
+
return conversation ? { ok: true as const, conversation } : { ok: false as const, reason: "not_found" as const };
|
|
1791
|
+
},
|
|
1792
|
+
|
|
1662
1793
|
archiveConversation: async (input) => {
|
|
1663
1794
|
const rows = await sql<{ id: string }[]>`
|
|
1664
1795
|
UPDATE ai.conversations
|
|
@@ -1672,6 +1803,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1672
1803
|
WHERE conversation_id = ai.conversations.id
|
|
1673
1804
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1674
1805
|
)
|
|
1806
|
+
AND NOT EXISTS (SELECT 1 FROM ai.queued_messages WHERE conversation_id = ai.conversations.id AND status IN ('pending','failed'))
|
|
1675
1807
|
RETURNING id
|
|
1676
1808
|
`;
|
|
1677
1809
|
return Boolean(rows[0]);
|
|
@@ -1727,7 +1859,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1727
1859
|
AND EXISTS (SELECT 1 FROM ai.messages m WHERE m.conversation_id = c.id)
|
|
1728
1860
|
AND NOT EXISTS (
|
|
1729
1861
|
SELECT 1 FROM ai.turns t
|
|
1730
|
-
WHERE t.conversation_id = c.id AND t.status IN ('queued', 'running', 'waiting_for_action')
|
|
1862
|
+
WHERE t.conversation_id = c.id AND NOT COALESCE(t.run_config ? 'background', false) AND t.status IN ('queued', 'running', 'waiting_for_action')
|
|
1731
1863
|
)
|
|
1732
1864
|
ORDER BY c.updated_at ASC
|
|
1733
1865
|
LIMIT ${limit}
|
|
@@ -2075,7 +2207,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2075
2207
|
listTurnMessages: async (input) => {
|
|
2076
2208
|
const rows = await sql<MessageRow[]>`
|
|
2077
2209
|
SELECT *
|
|
2078
|
-
FROM ai.messages
|
|
2210
|
+
FROM (SELECT * FROM ai.messages UNION ALL SELECT * FROM ai.task_messages) messages
|
|
2079
2211
|
WHERE conversation_id = ${input.conversationId}
|
|
2080
2212
|
AND loop_id = ${input.loopId}
|
|
2081
2213
|
AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
|
|
@@ -2139,7 +2271,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2139
2271
|
usage,
|
|
2140
2272
|
stop_reason,
|
|
2141
2273
|
loop_aggregate,
|
|
2142
|
-
loop_done_reason
|
|
2274
|
+
loop_done_reason,
|
|
2275
|
+
meta
|
|
2143
2276
|
)
|
|
2144
2277
|
SELECT
|
|
2145
2278
|
short_id,
|
|
@@ -2155,7 +2288,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2155
2288
|
usage,
|
|
2156
2289
|
stop_reason,
|
|
2157
2290
|
loop_aggregate,
|
|
2158
|
-
loop_done_reason
|
|
2291
|
+
loop_done_reason,
|
|
2292
|
+
CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
|
|
2159
2293
|
FROM ai.messages
|
|
2160
2294
|
WHERE conversation_id = ${input.sourceConversationId}
|
|
2161
2295
|
AND compacted_at IS NULL
|
|
@@ -2184,13 +2318,15 @@ export const aiConversations: AiConversationService = {
|
|
|
2184
2318
|
|
|
2185
2319
|
setLatestAssistantLoopAggregate: async (input) => {
|
|
2186
2320
|
const loopId = input.loopId ?? null;
|
|
2321
|
+
const config = loopId ? await aiConversations.getTurnRunConfig({ conversationId: input.conversationId, turnId: loopId }) : null;
|
|
2322
|
+
const table = config?.kind === "chat" && config.background ? "ai.task_messages" : "ai.messages";
|
|
2187
2323
|
await sql`
|
|
2188
|
-
UPDATE
|
|
2189
|
-
SET loop_aggregate = ${JSON.stringify(input.aggregate)}::jsonb,
|
|
2324
|
+
UPDATE ${sql(table)}
|
|
2325
|
+
SET loop_aggregate = ${JSON.stringify(input.aggregate)}::text::jsonb,
|
|
2190
2326
|
loop_done_reason = ${input.doneReason}
|
|
2191
2327
|
WHERE id = (
|
|
2192
2328
|
SELECT id
|
|
2193
|
-
FROM
|
|
2329
|
+
FROM ${sql(table)}
|
|
2194
2330
|
WHERE conversation_id = ${input.conversationId}
|
|
2195
2331
|
AND compacted_at IS NULL
|
|
2196
2332
|
AND kind = 'message'
|
|
@@ -2214,9 +2350,18 @@ export const aiConversations: AiConversationService = {
|
|
|
2214
2350
|
WHERE conversation_id = ${input.conversationId}
|
|
2215
2351
|
AND compacted_at IS NULL
|
|
2216
2352
|
AND seq <= ${checkpointSeq}
|
|
2353
|
+
AND ${visibleToTurn(input.turnId)}
|
|
2217
2354
|
`;
|
|
2218
2355
|
if ((rows[0]?.count ?? 0) === 0) return;
|
|
2219
2356
|
|
|
2357
|
+
const plans = await tx<{ plan: unknown }[]>`
|
|
2358
|
+
SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
|
|
2359
|
+
FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq <= ${checkpointSeq}
|
|
2360
|
+
AND ${visibleToTurn(input.turnId)}
|
|
2361
|
+
AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
|
|
2362
|
+
ORDER BY seq DESC, id DESC LIMIT 1
|
|
2363
|
+
`;
|
|
2364
|
+
const todoPlan = parseAiTodoPlan(plans[0]?.plan);
|
|
2220
2365
|
const archived = await tx<{ count: number }[]>`
|
|
2221
2366
|
WITH archived AS (
|
|
2222
2367
|
UPDATE ai.messages
|
|
@@ -2224,6 +2369,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2224
2369
|
WHERE conversation_id = ${input.conversationId}
|
|
2225
2370
|
AND compacted_at IS NULL
|
|
2226
2371
|
AND seq <= ${checkpointSeq}
|
|
2372
|
+
AND ${visibleToTurn(input.turnId)}
|
|
2227
2373
|
RETURNING id
|
|
2228
2374
|
)
|
|
2229
2375
|
SELECT COUNT(*)::int AS count FROM archived
|
|
@@ -2232,41 +2378,32 @@ export const aiConversations: AiConversationService = {
|
|
|
2232
2378
|
await insertMessageLocked(
|
|
2233
2379
|
{
|
|
2234
2380
|
conversationId: input.conversationId,
|
|
2235
|
-
message: input.summary,
|
|
2381
|
+
message: todoPlan && input.summary.role === "assistant" ? { ...input.summary, content: [...input.summary.content, { type: "text", text: `Working plan at this checkpoint:\n${JSON.stringify(todoPlan)}` }] } : input.summary,
|
|
2236
2382
|
kind: "summary",
|
|
2237
2383
|
seq: checkpointSeq,
|
|
2238
2384
|
loopId: null,
|
|
2239
2385
|
modelProfileId: input.modelProfileId ?? null,
|
|
2240
|
-
meta: { compactedCount: archived[0]?.count ?? 0 },
|
|
2386
|
+
meta: { compactedCount: archived[0]?.count ?? 0, ...(todoPlan ? { todoPlan } : {}) },
|
|
2241
2387
|
},
|
|
2242
2388
|
tx,
|
|
2243
2389
|
);
|
|
2244
2390
|
});
|
|
2245
2391
|
},
|
|
2246
2392
|
|
|
2247
|
-
createCompactionTurn: async (input) => {
|
|
2248
|
-
const
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
run_config
|
|
2257
|
-
)
|
|
2258
|
-
VALUES (
|
|
2259
|
-
${shortId},
|
|
2260
|
-
${input.conversationId},
|
|
2261
|
-
${input.modelProfileId},
|
|
2262
|
-
'queued',
|
|
2263
|
-
(${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb
|
|
2264
|
-
)
|
|
2393
|
+
createCompactionTurn: async (input) => sql.begin(async (tx) => {
|
|
2394
|
+
const [conversation] = await tx<{ id: string }[]>`
|
|
2395
|
+
SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid AND archived_at IS NULL FOR UPDATE
|
|
2396
|
+
`;
|
|
2397
|
+
if (!conversation) throw new Error("Conversation not found.");
|
|
2398
|
+
const rows = await withAiShortIdForDb(tx, "idx_ai_turns_conversation_short_id", (attempt, shortId) => attempt<TurnRow[]>`
|
|
2399
|
+
INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
|
|
2400
|
+
VALUES (${shortId}, ${input.conversationId}, ${input.modelProfileId}, 'queued',
|
|
2401
|
+
(${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb)
|
|
2265
2402
|
RETURNING *
|
|
2266
|
-
|
|
2267
|
-
)
|
|
2403
|
+
`);
|
|
2404
|
+
await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
|
|
2268
2405
|
return rowToTurn(rows[0]!);
|
|
2269
|
-
},
|
|
2406
|
+
}),
|
|
2270
2407
|
|
|
2271
2408
|
submitChatTurn: async (input) => {
|
|
2272
2409
|
return await sql.begin(async (tx) => {
|
|
@@ -2274,6 +2411,15 @@ export const aiConversations: AiConversationService = {
|
|
|
2274
2411
|
SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE
|
|
2275
2412
|
`;
|
|
2276
2413
|
if (!conversation) throw new Error("Conversation not found.");
|
|
2414
|
+
if (input.queuedMessageId) {
|
|
2415
|
+
const [head] = await tx<{id:string;status:string}[]>`SELECT id, status FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') ORDER BY position LIMIT 1`;
|
|
2416
|
+
const [busy] = await tx`SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued','running','waiting_for_action') LIMIT 1`;
|
|
2417
|
+
if (!head || head.id !== input.queuedMessageId || head.status !== 'pending' || busy) throw new Error("Queued message is no longer ready.");
|
|
2418
|
+
}
|
|
2419
|
+
if (!input.queuedMessageId) {
|
|
2420
|
+
const [queued] = await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
|
|
2421
|
+
if (queued) throw new Error("Queued messages must be sent first.");
|
|
2422
|
+
}
|
|
2277
2423
|
if (input.expectedDraftRevision !== undefined && Number(conversation.draft_revision) !== input.expectedDraftRevision) {
|
|
2278
2424
|
throw new Error("Conversation draft changed before the turn was submitted.");
|
|
2279
2425
|
}
|
|
@@ -2309,13 +2455,19 @@ export const aiConversations: AiConversationService = {
|
|
|
2309
2455
|
RETURNING *
|
|
2310
2456
|
`,
|
|
2311
2457
|
);
|
|
2458
|
+
// Queue acceptance already recorded the user's interaction time.
|
|
2459
|
+
if (!input.queuedMessageId) await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
|
|
2312
2460
|
const turn = rowToTurn(turnRows[0]!);
|
|
2313
2461
|
const attachedFiles = input.runConfig.files?.attached ?? [];
|
|
2314
2462
|
for (const file of attachedFiles) {
|
|
2315
|
-
const copied = input.
|
|
2463
|
+
const copied = input.queuedMessageId
|
|
2464
|
+
? await tx<{path:string}[]>`INSERT INTO ai.turn_files(turn_id,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version)
|
|
2465
|
+
SELECT ${turn.id}::uuid,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version FROM ai.queued_message_files
|
|
2466
|
+
WHERE message_id = ${input.queuedMessageId}::uuid AND path = ${file.path} RETURNING path`
|
|
2467
|
+
: input.retrySourceTurnId
|
|
2316
2468
|
? await tx<{ path: string }[]>`
|
|
2317
|
-
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
|
|
2318
|
-
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
|
|
2469
|
+
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
|
|
2470
|
+
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
|
|
2319
2471
|
FROM ai.turn_files
|
|
2320
2472
|
WHERE turn_id = ${input.retrySourceTurnId}::uuid
|
|
2321
2473
|
AND path = ${file.path}
|
|
@@ -2325,8 +2477,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2325
2477
|
RETURNING path
|
|
2326
2478
|
`
|
|
2327
2479
|
: await tx<{ path: string }[]>`
|
|
2328
|
-
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
|
|
2329
|
-
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
|
|
2480
|
+
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
|
|
2481
|
+
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
|
|
2330
2482
|
FROM ai.files
|
|
2331
2483
|
WHERE conversation_id = ${input.conversationId}::uuid
|
|
2332
2484
|
AND path = ${file.path}
|
|
@@ -2342,6 +2494,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2342
2494
|
conversationId: input.conversationId,
|
|
2343
2495
|
message: input.userMessage,
|
|
2344
2496
|
loopId: turn.id,
|
|
2497
|
+
meta: input.expectedDraftRevision === undefined ? undefined : { submittedDraftRevision: input.expectedDraftRevision },
|
|
2345
2498
|
},
|
|
2346
2499
|
tx,
|
|
2347
2500
|
);
|
|
@@ -2375,6 +2528,10 @@ export const aiConversations: AiConversationService = {
|
|
|
2375
2528
|
WHERE id = ${input.conversationId}::uuid
|
|
2376
2529
|
`;
|
|
2377
2530
|
}
|
|
2531
|
+
if (input.queuedMessageId) {
|
|
2532
|
+
await tx`UPDATE ai.queued_messages SET status = 'submitted', submission = '{}'::jsonb, content = '[]'::jsonb WHERE id = ${input.queuedMessageId}::uuid`;
|
|
2533
|
+
await tx`DELETE FROM ai.queued_message_files WHERE message_id = ${input.queuedMessageId}::uuid`;
|
|
2534
|
+
}
|
|
2378
2535
|
return { turn, message: rowToMessage(messageRow) };
|
|
2379
2536
|
});
|
|
2380
2537
|
},
|
|
@@ -2390,6 +2547,12 @@ export const aiConversations: AiConversationService = {
|
|
|
2390
2547
|
return rows[0]?.run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null;
|
|
2391
2548
|
},
|
|
2392
2549
|
|
|
2550
|
+
getLatestTurn: async (input) => {
|
|
2551
|
+
const [row] = await sql<TurnRow[]>`SELECT * FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false)
|
|
2552
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`;
|
|
2553
|
+
return row ? rowToTurn(row) : null;
|
|
2554
|
+
},
|
|
2555
|
+
|
|
2393
2556
|
getTurn: async (input) => {
|
|
2394
2557
|
const rows = await sql<TurnRow[]>`
|
|
2395
2558
|
SELECT *
|
|
@@ -2417,6 +2580,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2417
2580
|
SELECT *
|
|
2418
2581
|
FROM ai.turns
|
|
2419
2582
|
WHERE conversation_id = ${input.conversationId}
|
|
2583
|
+
AND NOT COALESCE(run_config ? 'background', false)
|
|
2420
2584
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
2421
2585
|
ORDER BY created_at DESC
|
|
2422
2586
|
LIMIT 1
|
|
@@ -2431,7 +2595,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2431
2595
|
|
|
2432
2596
|
claimTurn: async (input) => {
|
|
2433
2597
|
const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
|
|
2434
|
-
const runBudgetMs =
|
|
2598
|
+
const runBudgetMs = Number.isFinite(input.runBudgetMs) && input.runBudgetMs >= 0 ? input.runBudgetMs : 30 * 60_000;
|
|
2435
2599
|
const maxAttempts = Math.max(1, Math.floor(input.maxAttempts));
|
|
2436
2600
|
const rows = await sql<TurnRow[]>`
|
|
2437
2601
|
UPDATE ai.turns
|
|
@@ -2440,9 +2604,11 @@ export const aiConversations: AiConversationService = {
|
|
|
2440
2604
|
lease_owner = ${input.leaseOwner},
|
|
2441
2605
|
lease_expires_at = now() + (${leaseMs} * interval '1 millisecond'),
|
|
2442
2606
|
heartbeat_at = now(),
|
|
2607
|
+
run_budget_ms = COALESCE(run_budget_ms, ${runBudgetMs}),
|
|
2443
2608
|
deadline = CASE
|
|
2444
|
-
WHEN
|
|
2445
|
-
|
|
2609
|
+
WHEN COALESCE(run_budget_ms, ${runBudgetMs}) = 0 THEN NULL
|
|
2610
|
+
WHEN ${input.from} = 'waiting' OR deadline IS NULL THEN now() + (COALESCE(run_budget_ms, ${runBudgetMs}) * interval '1 millisecond')
|
|
2611
|
+
ELSE deadline
|
|
2446
2612
|
END
|
|
2447
2613
|
WHERE id = ${input.turnId}
|
|
2448
2614
|
AND conversation_id = ${input.conversationId}
|
|
@@ -2686,7 +2852,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2686
2852
|
UPDATE ai.turns
|
|
2687
2853
|
SET status = 'failed',
|
|
2688
2854
|
completed_at = now(),
|
|
2689
|
-
error = '
|
|
2855
|
+
error = 'Run time limit reached. You can continue the task in a new message.',
|
|
2690
2856
|
lease_owner = NULL,
|
|
2691
2857
|
lease_expires_at = NULL,
|
|
2692
2858
|
live_blocks = NULL
|
|
@@ -2853,14 +3019,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2853
3019
|
${input.callId},
|
|
2854
3020
|
${input.kind},
|
|
2855
3021
|
${input.name},
|
|
2856
|
-
${JSON.stringify(input.args ?? null)}::jsonb,
|
|
3022
|
+
${JSON.stringify(input.args ?? null)}::text::jsonb,
|
|
2857
3023
|
${input.message ?? null},
|
|
2858
|
-
${input.review ? JSON.stringify(input.review) : null}::jsonb,
|
|
3024
|
+
${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
|
|
2859
3025
|
${input.approvalScope},
|
|
2860
3026
|
${input.allowAlways},
|
|
2861
3027
|
${input.frontendMode ?? null},
|
|
2862
3028
|
${input.resolvedEvent ? "resolved" : "pending"},
|
|
2863
|
-
${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::jsonb,
|
|
3029
|
+
${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
|
|
2864
3030
|
CASE WHEN ${Boolean(input.resolvedEvent)} THEN now() ELSE NULL END
|
|
2865
3031
|
)
|
|
2866
3032
|
ON CONFLICT (turn_id, call_id)
|
|
@@ -2928,7 +3094,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2928
3094
|
const rows = await sql<PendingActionRow[]>`
|
|
2929
3095
|
UPDATE ai.pending_actions
|
|
2930
3096
|
SET status = 'resolved',
|
|
2931
|
-
resolved_event = ${JSON.stringify(input.event)}::jsonb,
|
|
3097
|
+
resolved_event = ${JSON.stringify(input.event)}::text::jsonb,
|
|
2932
3098
|
resolved_at = now()
|
|
2933
3099
|
WHERE conversation_id = ${input.conversationId}
|
|
2934
3100
|
AND turn_id = ${input.turnId}
|
|
@@ -3054,7 +3220,21 @@ export const aiConversations: AiConversationService = {
|
|
|
3054
3220
|
createSessionStore: (input): SessionStore => ({
|
|
3055
3221
|
load: async (): Promise<StoreEntry[]> => {
|
|
3056
3222
|
// The loop must only ever see the active model context, never archived history.
|
|
3057
|
-
|
|
3223
|
+
if (input.background && input.turnId) {
|
|
3224
|
+
const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
|
|
3225
|
+
const context = input.background.context;
|
|
3226
|
+
return [...context, ...own.map(row => ({ seq: context.length + row.seq, kind: row.kind, message: row.message }))];
|
|
3227
|
+
}
|
|
3228
|
+
// Results delivered after this turn started belong to the next turn's context.
|
|
3229
|
+
// Compare durable message sequences rather than wall-clock timestamps.
|
|
3230
|
+
const rows = input.turnId
|
|
3231
|
+
? (await sql<MessageRow[]>`
|
|
3232
|
+
SELECT * FROM ai.messages
|
|
3233
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND compacted_at IS NULL
|
|
3234
|
+
AND ${visibleToTurn(input.turnId)}
|
|
3235
|
+
ORDER BY seq ASC
|
|
3236
|
+
`).map(rowToMessage)
|
|
3237
|
+
: await aiConversations.listContextMessages({ conversationId: input.conversationId });
|
|
3058
3238
|
return rows.map((row) => ({
|
|
3059
3239
|
seq: row.seq,
|
|
3060
3240
|
kind: row.kind,
|
|
@@ -3077,19 +3257,21 @@ export const aiConversations: AiConversationService = {
|
|
|
3077
3257
|
|
|
3078
3258
|
if (input.turnId && input.leaseOwner) {
|
|
3079
3259
|
const appended = await appendTurnOwnedMessage({
|
|
3260
|
+
background: Boolean(input.background),
|
|
3080
3261
|
conversationId: input.conversationId,
|
|
3081
3262
|
turnId: input.turnId,
|
|
3082
3263
|
leaseOwner: input.leaseOwner,
|
|
3083
3264
|
message,
|
|
3084
3265
|
kind: opts?.kind,
|
|
3085
3266
|
seq: opts?.seq,
|
|
3086
|
-
loopId: opts?.kind === "summary" ? null : input.turnId,
|
|
3267
|
+
loopId: opts?.kind === "summary" && !input.background ? null : input.turnId,
|
|
3087
3268
|
modelProfileId: input.modelProfileId,
|
|
3088
3269
|
meta,
|
|
3089
3270
|
});
|
|
3090
3271
|
if (!appended) {
|
|
3091
3272
|
throw new Error("AI turn lost its lease while writing a message.");
|
|
3092
3273
|
}
|
|
3274
|
+
await input.onMessage?.(appended);
|
|
3093
3275
|
return;
|
|
3094
3276
|
}
|
|
3095
3277
|
|