pragma-so 0.1.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/cli/index.ts +882 -0
- package/index.ts +3 -0
- package/package.json +53 -0
- package/server/connectorBinaries.ts +103 -0
- package/server/connectorRegistry.ts +158 -0
- package/server/conversation/adapterRegistry.ts +53 -0
- package/server/conversation/adapters/claudeAdapter.ts +138 -0
- package/server/conversation/adapters/codexAdapter.ts +142 -0
- package/server/conversation/adapters.ts +224 -0
- package/server/conversation/executeRunner.ts +1191 -0
- package/server/conversation/gitWorkflow.ts +1037 -0
- package/server/conversation/models.ts +23 -0
- package/server/conversation/pragmaCli.ts +34 -0
- package/server/conversation/prompts.ts +335 -0
- package/server/conversation/store.ts +805 -0
- package/server/conversation/titleGenerator.ts +106 -0
- package/server/conversation/turnRunner.ts +365 -0
- package/server/conversation/types.ts +134 -0
- package/server/db.ts +837 -0
- package/server/http/middleware.ts +31 -0
- package/server/http/schemas.ts +430 -0
- package/server/http/validators.ts +38 -0
- package/server/index.ts +6560 -0
- package/server/process/runCommand.ts +142 -0
- package/server/stores/agentStore.ts +167 -0
- package/server/stores/connectorStore.ts +299 -0
- package/server/stores/humanStore.ts +28 -0
- package/server/stores/skillStore.ts +127 -0
- package/server/stores/taskStore.ts +371 -0
- package/shared/net.ts +24 -0
- package/tsconfig.json +14 -0
- package/ui/index.html +14 -0
- package/ui/public/favicon-32.png +0 -0
- package/ui/public/favicon.png +0 -0
- package/ui/src/App.jsx +1338 -0
- package/ui/src/api.js +954 -0
- package/ui/src/components/CodeView.jsx +319 -0
- package/ui/src/components/ConnectionsView.jsx +1004 -0
- package/ui/src/components/ContextView.jsx +315 -0
- package/ui/src/components/ConversationDrawer.jsx +963 -0
- package/ui/src/components/EmptyPane.jsx +20 -0
- package/ui/src/components/FeedView.jsx +773 -0
- package/ui/src/components/FilesView.jsx +257 -0
- package/ui/src/components/InlineChatView.jsx +158 -0
- package/ui/src/components/InputBar.jsx +476 -0
- package/ui/src/components/OnboardingModal.jsx +112 -0
- package/ui/src/components/OutputPanel.jsx +658 -0
- package/ui/src/components/PlanProposalPanel.jsx +177 -0
- package/ui/src/components/RightPanel.jsx +951 -0
- package/ui/src/components/SettingsView.jsx +186 -0
- package/ui/src/components/Sidebar.jsx +247 -0
- package/ui/src/components/TestingPane.jsx +198 -0
- package/ui/src/components/testing/ApiTesterPanel.jsx +187 -0
- package/ui/src/components/testing/LogViewerPanel.jsx +64 -0
- package/ui/src/components/testing/TerminalPanel.jsx +104 -0
- package/ui/src/components/testing/WebPreviewPanel.jsx +78 -0
- package/ui/src/hooks/useAgents.js +81 -0
- package/ui/src/hooks/useConversation.js +252 -0
- package/ui/src/hooks/useTasks.js +161 -0
- package/ui/src/hooks/useWorkspace.js +259 -0
- package/ui/src/lib/agentIcon.js +10 -0
- package/ui/src/lib/conversationUtils.js +575 -0
- package/ui/src/main.jsx +10 -0
- package/ui/src/styles.css +6899 -0
- package/ui/vite.config.mjs +6 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { DEFAULT_AGENT_ID } from "../db";
|
|
4
|
+
import { getConversationAdapter } from "./adapters";
|
|
5
|
+
import { getAdapterDefinition } from "./adapterRegistry";
|
|
6
|
+
|
|
7
|
+
const TITLE_SYSTEM_PROMPT = [
|
|
8
|
+
"Generate a concise title (3-8 words) that summarizes the conversation.",
|
|
9
|
+
"Rules:",
|
|
10
|
+
"- No quotes around the title",
|
|
11
|
+
"- No punctuation at the end",
|
|
12
|
+
"- Use sentence case",
|
|
13
|
+
"- Output ONLY the title, nothing else",
|
|
14
|
+
].join("\n");
|
|
15
|
+
|
|
16
|
+
const TITLE_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
export async function generateTitle(
|
|
19
|
+
db: PGlite,
|
|
20
|
+
userMessage: string,
|
|
21
|
+
assistantMessage: string,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
try {
|
|
24
|
+
const agent = await getAgentRow(db);
|
|
25
|
+
if (!agent) {
|
|
26
|
+
return deriveChatTitleFallback(userMessage, assistantMessage);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const harness = agent.harness;
|
|
30
|
+
const def = getAdapterDefinition(harness);
|
|
31
|
+
const modelId = def.titleModelId ?? agent.model_id;
|
|
32
|
+
const adapter = getConversationAdapter(harness);
|
|
33
|
+
|
|
34
|
+
const prompt = buildTitlePrompt(userMessage, assistantMessage);
|
|
35
|
+
|
|
36
|
+
const result = await Promise.race([
|
|
37
|
+
adapter.sendTurn({
|
|
38
|
+
prompt,
|
|
39
|
+
modelId,
|
|
40
|
+
sessionId: null,
|
|
41
|
+
cwd: homedir(),
|
|
42
|
+
mode: "chat",
|
|
43
|
+
onEvent: () => {},
|
|
44
|
+
}),
|
|
45
|
+
rejectAfterTimeout(TITLE_TIMEOUT_MS),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const title = result.finalText.trim();
|
|
49
|
+
if (title && title.length <= 100) {
|
|
50
|
+
return title;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return deriveChatTitleFallback(userMessage, assistantMessage);
|
|
54
|
+
} catch {
|
|
55
|
+
return deriveChatTitleFallback(userMessage, assistantMessage);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildTitlePrompt(userMessage: string, assistantMessage: string): string {
|
|
60
|
+
const parts = [TITLE_SYSTEM_PROMPT, "", "User message:", userMessage.slice(0, 500)];
|
|
61
|
+
if (assistantMessage) {
|
|
62
|
+
parts.push("", "Assistant response:", assistantMessage.slice(0, 500));
|
|
63
|
+
}
|
|
64
|
+
return parts.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function rejectAfterTimeout(ms: number): Promise<never> {
|
|
68
|
+
return new Promise((_, reject) => {
|
|
69
|
+
setTimeout(() => reject(new Error("Title generation timed out")), ms);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function getAgentRow(
|
|
74
|
+
db: PGlite,
|
|
75
|
+
): Promise<{ harness: string; model_id: string } | null> {
|
|
76
|
+
const result = await db.query<{ harness: string; model_id: string }>(
|
|
77
|
+
`SELECT harness, model_id FROM agents WHERE id = $1 LIMIT 1`,
|
|
78
|
+
[DEFAULT_AGENT_ID],
|
|
79
|
+
);
|
|
80
|
+
return result.rows[0] ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function deriveChatTitleFallback(userMessage: string, assistantMessage: string): string {
|
|
84
|
+
const userFirst = firstSentence(userMessage);
|
|
85
|
+
if (userFirst) {
|
|
86
|
+
return truncate(userFirst, 80);
|
|
87
|
+
}
|
|
88
|
+
const assistantFirst = firstSentence(assistantMessage);
|
|
89
|
+
if (assistantFirst) {
|
|
90
|
+
return truncate(assistantFirst, 80);
|
|
91
|
+
}
|
|
92
|
+
return "New chat";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function firstSentence(text: string): string {
|
|
96
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
97
|
+
if (!normalized) return "";
|
|
98
|
+
const match = normalized.match(/(.+?[.!?])(?:\s|$)/);
|
|
99
|
+
return match?.[1]?.trim() ?? normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function truncate(value: string, maxLength: number): string {
|
|
103
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
104
|
+
if (normalized.length <= maxLength) return normalized;
|
|
105
|
+
return `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { getConversationAdapter } from "./adapters";
|
|
4
|
+
import { buildConversationHistoryBlock, buildPrompt } from "./prompts";
|
|
5
|
+
import {
|
|
6
|
+
completeTurn,
|
|
7
|
+
ensureConversationSchema,
|
|
8
|
+
failTurn,
|
|
9
|
+
getThreadById,
|
|
10
|
+
getThreadMessages,
|
|
11
|
+
insertEvent,
|
|
12
|
+
insertMessage,
|
|
13
|
+
updateChatThreadMetadata,
|
|
14
|
+
updateThreadSession,
|
|
15
|
+
} from "./store";
|
|
16
|
+
import type { ConversationMode, HarnessId, ReasoningEffort, TaskStatus } from "./types";
|
|
17
|
+
import { DEFAULT_AGENT_ID, getWorkspacePaths, openDatabase } from "../db";
|
|
18
|
+
|
|
19
|
+
type TurnInput = {
|
|
20
|
+
workspaceName: string;
|
|
21
|
+
threadId: string;
|
|
22
|
+
turnId: string;
|
|
23
|
+
userMessageId: string;
|
|
24
|
+
isNewThread: boolean;
|
|
25
|
+
message: string;
|
|
26
|
+
mode: ConversationMode;
|
|
27
|
+
harness: HarnessId;
|
|
28
|
+
modelLabel: string;
|
|
29
|
+
modelId: string;
|
|
30
|
+
reasoningEffort?: ReasoningEffort;
|
|
31
|
+
requestedRecipientAgentId?: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type TurnRunnerCallbacks = {
|
|
35
|
+
apiUrl: string;
|
|
36
|
+
pragmaCliCommand: string;
|
|
37
|
+
onThreadUpdated?: (input: { workspaceName: string; threadId: string; source: string }) => void | Promise<void>;
|
|
38
|
+
onTaskStatusChanged?: (input: {
|
|
39
|
+
workspaceName: string;
|
|
40
|
+
taskId: string;
|
|
41
|
+
threadId: string;
|
|
42
|
+
status: string;
|
|
43
|
+
source: string;
|
|
44
|
+
}) => void | Promise<void>;
|
|
45
|
+
getAgentRow: (
|
|
46
|
+
db: Awaited<ReturnType<typeof openDatabase>>,
|
|
47
|
+
id: string,
|
|
48
|
+
) => Promise<{ id: string; name: string; harness: HarnessId; model_label: string; model_id: string; agent_file: string | null } | null>;
|
|
49
|
+
listPlanWorkerCandidates: (
|
|
50
|
+
db: Awaited<ReturnType<typeof openDatabase>>,
|
|
51
|
+
) => Promise<Array<{ id: string; name: string; description: string | null; harness: HarnessId; model_label: string }>>;
|
|
52
|
+
isDirectoryEmpty: (path: string) => Promise<boolean>;
|
|
53
|
+
getStoredPlanRecipientForTurn: (
|
|
54
|
+
db: Awaited<ReturnType<typeof openDatabase>>,
|
|
55
|
+
turnId: string,
|
|
56
|
+
) => Promise<string | null>;
|
|
57
|
+
buildConversationAgentEnv: (input: {
|
|
58
|
+
apiUrl: string;
|
|
59
|
+
pragmaCliCommand: string;
|
|
60
|
+
workspaceName: string;
|
|
61
|
+
threadId: string;
|
|
62
|
+
turnId: string;
|
|
63
|
+
agentId: string;
|
|
64
|
+
taskId?: string | null;
|
|
65
|
+
}) => Record<string, string>;
|
|
66
|
+
emitTaskStatus: (workspaceName: string, taskId: string, status: TaskStatus, source: string) => void;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Active AbortControllers keyed by turnId, so we can cancel in-progress turns
|
|
70
|
+
const ACTIVE_TURN_ABORTS = new Map<string, AbortController>();
|
|
71
|
+
|
|
72
|
+
export class TurnRunner {
|
|
73
|
+
private readonly callbacks: TurnRunnerCallbacks;
|
|
74
|
+
|
|
75
|
+
constructor(callbacks: TurnRunnerCallbacks) {
|
|
76
|
+
this.callbacks = callbacks;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fire-and-forget: kicks off the turn in the background.
|
|
81
|
+
* Returns immediately. The turn runs to completion (or failure)
|
|
82
|
+
* independently of any client connection.
|
|
83
|
+
*/
|
|
84
|
+
execute(input: TurnInput): void {
|
|
85
|
+
runTurn(input, this.callbacks).catch((error: unknown) => {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
console.error(`Turn runner failed for ${input.turnId}: ${message}`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Abort an in-progress turn. Returns true if a turn was found and aborted.
|
|
93
|
+
*/
|
|
94
|
+
abort(turnId: string): boolean {
|
|
95
|
+
const controller = ACTIVE_TURN_ABORTS.get(turnId);
|
|
96
|
+
if (controller) {
|
|
97
|
+
controller.abort();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runTurn(
|
|
105
|
+
input: TurnInput,
|
|
106
|
+
callbacks: TurnRunnerCallbacks,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const abortController = new AbortController();
|
|
109
|
+
ACTIVE_TURN_ABORTS.set(input.turnId, abortController);
|
|
110
|
+
|
|
111
|
+
const db = await openDatabase(input.workspaceName);
|
|
112
|
+
await ensureConversationSchema(db);
|
|
113
|
+
const paths = getWorkspacePaths(input.workspaceName);
|
|
114
|
+
const adapter = getConversationAdapter(input.harness);
|
|
115
|
+
|
|
116
|
+
const notifyThreadUpdated = async (source: string): Promise<void> => {
|
|
117
|
+
await callbacks.onThreadUpdated?.({
|
|
118
|
+
workspaceName: input.workspaceName,
|
|
119
|
+
threadId: input.threadId,
|
|
120
|
+
source,
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const insertThreadEvent = async (payload: {
|
|
125
|
+
id: string;
|
|
126
|
+
threadId: string;
|
|
127
|
+
turnId: string | null;
|
|
128
|
+
eventName: string;
|
|
129
|
+
payload: unknown;
|
|
130
|
+
}): Promise<void> => {
|
|
131
|
+
await insertEvent(db, payload);
|
|
132
|
+
await notifyThreadUpdated(payload.eventName);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const thread = await getThreadById(db, input.threadId);
|
|
137
|
+
if (!thread) {
|
|
138
|
+
throw new Error(`Thread not found: ${input.threadId}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Emit thread_started event for new threads
|
|
142
|
+
if (input.isNewThread) {
|
|
143
|
+
const startedPayload = { thread_id: input.threadId };
|
|
144
|
+
await insertThreadEvent({
|
|
145
|
+
id: `evt_${randomUUID().slice(0, 12)}`,
|
|
146
|
+
threadId: input.threadId,
|
|
147
|
+
turnId: input.turnId,
|
|
148
|
+
eventName: "thread_started",
|
|
149
|
+
payload: startedPayload,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Emit user_message_saved event
|
|
154
|
+
await insertThreadEvent({
|
|
155
|
+
id: `evt_${randomUUID().slice(0, 12)}`,
|
|
156
|
+
threadId: input.threadId,
|
|
157
|
+
turnId: input.turnId,
|
|
158
|
+
eventName: "user_message_saved",
|
|
159
|
+
payload: { message_id: input.userMessageId },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Build prompt
|
|
163
|
+
let assistantText = "";
|
|
164
|
+
const planPromptCandidates =
|
|
165
|
+
input.mode === "plan" ? await callbacks.listPlanWorkerCandidates(db) : [];
|
|
166
|
+
const workspaceIsEmpty =
|
|
167
|
+
input.mode === "plan" ? await callbacks.isDirectoryEmpty(paths.codeDir) : false;
|
|
168
|
+
const chatCodeRepos =
|
|
169
|
+
input.mode === "chat"
|
|
170
|
+
? (await readdir(paths.codeDir, { withFileTypes: true }).catch(() => []))
|
|
171
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
172
|
+
.map((e) => e.name)
|
|
173
|
+
.sort()
|
|
174
|
+
: [];
|
|
175
|
+
// Fetch prior messages for conversation history fallback
|
|
176
|
+
const hasSessionId = Boolean(thread.harness_session_id);
|
|
177
|
+
let conversationHistory: Array<{ role: string; content: string }> | undefined;
|
|
178
|
+
|
|
179
|
+
if (!hasSessionId) {
|
|
180
|
+
// No session ID — full history fallback (last 40 messages)
|
|
181
|
+
const priorMessages = await getThreadMessages(db, input.threadId, 40);
|
|
182
|
+
if (priorMessages.length > 0) {
|
|
183
|
+
conversationHistory = priorMessages;
|
|
184
|
+
}
|
|
185
|
+
} else if (input.mode === "chat") {
|
|
186
|
+
// Session ID exists — include brief history as safety net (last 10 messages)
|
|
187
|
+
const priorMessages = await getThreadMessages(db, input.threadId, 10);
|
|
188
|
+
if (priorMessages.length > 0) {
|
|
189
|
+
conversationHistory = priorMessages;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let prompt = buildPrompt(input.mode, input.message, input.reasoningEffort, callbacks.pragmaCliCommand, {
|
|
194
|
+
planCandidates: planPromptCandidates.map((candidate) => ({
|
|
195
|
+
id: candidate.id,
|
|
196
|
+
name: candidate.name,
|
|
197
|
+
description: candidate.description,
|
|
198
|
+
harness: candidate.harness,
|
|
199
|
+
modelLabel: candidate.model_label,
|
|
200
|
+
})),
|
|
201
|
+
workspaceIsEmpty,
|
|
202
|
+
workspaceDir: paths.workspaceDir,
|
|
203
|
+
codeRepos: chatCodeRepos,
|
|
204
|
+
conversationHistory: hasSessionId ? conversationHistory : undefined,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// When no session ID, prepend full history block directly to the prompt
|
|
208
|
+
if (!hasSessionId && conversationHistory && conversationHistory.length > 0) {
|
|
209
|
+
const historyBlock = buildConversationHistoryBlock(conversationHistory);
|
|
210
|
+
if (historyBlock) {
|
|
211
|
+
prompt = historyBlock + "\n\n" + prompt;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const result = await adapter.sendTurn({
|
|
216
|
+
prompt,
|
|
217
|
+
modelId: input.modelId,
|
|
218
|
+
sessionId: thread.harness_session_id,
|
|
219
|
+
cwd: paths.codeDir,
|
|
220
|
+
env: callbacks.buildConversationAgentEnv({
|
|
221
|
+
apiUrl: callbacks.apiUrl,
|
|
222
|
+
pragmaCliCommand: callbacks.pragmaCliCommand,
|
|
223
|
+
workspaceName: input.workspaceName,
|
|
224
|
+
threadId: input.threadId,
|
|
225
|
+
turnId: input.turnId,
|
|
226
|
+
agentId: DEFAULT_AGENT_ID,
|
|
227
|
+
taskId: thread.task_id,
|
|
228
|
+
}),
|
|
229
|
+
mode: input.mode,
|
|
230
|
+
reasoningEffort: input.reasoningEffort,
|
|
231
|
+
abortSignal: abortController.signal,
|
|
232
|
+
onEvent: async (event) => {
|
|
233
|
+
if (abortController.signal.aborted) return;
|
|
234
|
+
if (event.type === "assistant_text") {
|
|
235
|
+
assistantText = assistantText ? `${assistantText}\n${event.delta}` : event.delta;
|
|
236
|
+
await insertThreadEvent({
|
|
237
|
+
id: `evt_${randomUUID().slice(0, 12)}`,
|
|
238
|
+
threadId: input.threadId,
|
|
239
|
+
turnId: input.turnId,
|
|
240
|
+
eventName: "assistant_text",
|
|
241
|
+
payload: event,
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await insertThreadEvent({
|
|
247
|
+
id: `evt_${randomUUID().slice(0, 12)}`,
|
|
248
|
+
threadId: input.threadId,
|
|
249
|
+
turnId: input.turnId,
|
|
250
|
+
eventName: "tool_event",
|
|
251
|
+
payload: event,
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (result.aborted) {
|
|
257
|
+
const partialText = (result.finalText || assistantText || "").trim();
|
|
258
|
+
await failTurn(db, input.turnId, "Turn aborted.");
|
|
259
|
+
if (partialText) {
|
|
260
|
+
await insertMessage(db, {
|
|
261
|
+
id: `msg_${randomUUID().slice(0, 12)}`,
|
|
262
|
+
threadId: input.threadId,
|
|
263
|
+
turnId: input.turnId,
|
|
264
|
+
role: "assistant",
|
|
265
|
+
content: partialText,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
await insertThreadEvent({
|
|
269
|
+
id: `evt_${randomUUID().slice(0, 12)}`,
|
|
270
|
+
threadId: input.threadId,
|
|
271
|
+
turnId: input.turnId,
|
|
272
|
+
eventName: "turn_failed",
|
|
273
|
+
payload: { turn_id: input.turnId, reason: "aborted" },
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const finalAssistantText = (result.finalText || assistantText || "").trim();
|
|
279
|
+
const assistantMessageId = `msg_${randomUUID().slice(0, 12)}`;
|
|
280
|
+
const selectedPlanRecipientAgentId =
|
|
281
|
+
input.mode === "plan" ? await callbacks.getStoredPlanRecipientForTurn(db, input.turnId) : null;
|
|
282
|
+
|
|
283
|
+
await completeTurn(db, {
|
|
284
|
+
turnId: input.turnId,
|
|
285
|
+
assistantMessage: finalAssistantText,
|
|
286
|
+
selectedAgentId: selectedPlanRecipientAgentId,
|
|
287
|
+
selectionStatus: input.mode === "plan"
|
|
288
|
+
? (selectedPlanRecipientAgentId ? "auto_selected" : "recipient_required")
|
|
289
|
+
: null,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await insertMessage(db, {
|
|
293
|
+
id: assistantMessageId,
|
|
294
|
+
threadId: input.threadId,
|
|
295
|
+
turnId: input.turnId,
|
|
296
|
+
role: "assistant",
|
|
297
|
+
content: finalAssistantText,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Transition task from planning → planned when plan turn completes,
|
|
301
|
+
// but only if the agent didn't ask a question during this turn.
|
|
302
|
+
if (input.mode === "plan" && thread.task_id) {
|
|
303
|
+
const currentTask = await db.query<{ status: string }>(
|
|
304
|
+
`SELECT status FROM tasks WHERE id = $1 LIMIT 1`,
|
|
305
|
+
[thread.task_id],
|
|
306
|
+
);
|
|
307
|
+
const currentStatus = currentTask.rows[0]?.status;
|
|
308
|
+
if (currentStatus !== "waiting_for_question_response") {
|
|
309
|
+
if (selectedPlanRecipientAgentId) {
|
|
310
|
+
await db.query(
|
|
311
|
+
`UPDATE tasks SET status = 'planned', plan = $2, assigned_to = $3 WHERE id = $1`,
|
|
312
|
+
[thread.task_id, finalAssistantText, selectedPlanRecipientAgentId],
|
|
313
|
+
);
|
|
314
|
+
} else {
|
|
315
|
+
await db.query(
|
|
316
|
+
`UPDATE tasks SET status = 'planned', plan = $2 WHERE id = $1`,
|
|
317
|
+
[thread.task_id, finalAssistantText],
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
callbacks.emitTaskStatus(input.workspaceName, thread.task_id, "planned", "plan_completed");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (input.mode === "chat") {
|
|
325
|
+
// Title generation is handled upstream (before the turn starts) so only
|
|
326
|
+
// update the lastMessageAt timestamp here.
|
|
327
|
+
await updateChatThreadMetadata(db, {
|
|
328
|
+
threadId: input.threadId,
|
|
329
|
+
lastMessageAt: new Date().toISOString(),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await updateThreadSession(db, {
|
|
334
|
+
threadId: input.threadId,
|
|
335
|
+
sessionId: result.sessionId,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const turnCompletedPayload = {
|
|
339
|
+
turn_id: input.turnId,
|
|
340
|
+
assistant_message_id: assistantMessageId,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
await insertThreadEvent({
|
|
344
|
+
id: `evt_${randomUUID().slice(0, 12)}`,
|
|
345
|
+
threadId: input.threadId,
|
|
346
|
+
turnId: input.turnId,
|
|
347
|
+
eventName: "turn_completed",
|
|
348
|
+
payload: turnCompletedPayload,
|
|
349
|
+
});
|
|
350
|
+
} catch (error: unknown) {
|
|
351
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
352
|
+
|
|
353
|
+
await failTurn(db, input.turnId, messageText);
|
|
354
|
+
await insertThreadEvent({
|
|
355
|
+
id: `evt_${randomUUID().slice(0, 12)}`,
|
|
356
|
+
threadId: input.threadId,
|
|
357
|
+
turnId: input.turnId,
|
|
358
|
+
eventName: "error",
|
|
359
|
+
payload: { code: "TURN_ERROR", message: messageText },
|
|
360
|
+
});
|
|
361
|
+
} finally {
|
|
362
|
+
ACTIVE_TURN_ABORTS.delete(input.turnId);
|
|
363
|
+
await db.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export type ConversationMode = "chat" | "plan" | "execute";
|
|
2
|
+
|
|
3
|
+
export type HarnessId = string;
|
|
4
|
+
export type ReasoningEffort = "low" | "medium" | "high" | "extra_high";
|
|
5
|
+
|
|
6
|
+
export const TASK_STATUS_VALUES = [
|
|
7
|
+
"planning",
|
|
8
|
+
"planned",
|
|
9
|
+
"queued",
|
|
10
|
+
"orchestrating",
|
|
11
|
+
"running",
|
|
12
|
+
"waiting_for_recipient",
|
|
13
|
+
"waiting_for_question_response",
|
|
14
|
+
"waiting_for_help_response",
|
|
15
|
+
"pending_review",
|
|
16
|
+
"merging",
|
|
17
|
+
"needs_fix",
|
|
18
|
+
"completed",
|
|
19
|
+
"failed",
|
|
20
|
+
"cancelled",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export type TaskStatus = (typeof TASK_STATUS_VALUES)[number];
|
|
24
|
+
|
|
25
|
+
const TASK_STATUS_SET = new Set<string>(TASK_STATUS_VALUES);
|
|
26
|
+
|
|
27
|
+
export function isTaskStatus(value: unknown): value is TaskStatus {
|
|
28
|
+
return typeof value === "string" && TASK_STATUS_SET.has(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ConversationStatus = "open" | "closed";
|
|
32
|
+
|
|
33
|
+
export type ConversationThread = {
|
|
34
|
+
id: string;
|
|
35
|
+
mode: ConversationMode;
|
|
36
|
+
status: ConversationStatus;
|
|
37
|
+
harness: HarnessId;
|
|
38
|
+
model_label: string;
|
|
39
|
+
model_id: string;
|
|
40
|
+
harness_session_id: string | null;
|
|
41
|
+
task_id: string | null;
|
|
42
|
+
source_thread_id: string | null;
|
|
43
|
+
chat_title?: string | null;
|
|
44
|
+
chat_preview?: string | null;
|
|
45
|
+
chat_last_message_at?: string | null;
|
|
46
|
+
created_at: string;
|
|
47
|
+
updated_at: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type ChatThreadListItem = {
|
|
51
|
+
id: string;
|
|
52
|
+
chat_title: string | null;
|
|
53
|
+
chat_preview: string | null;
|
|
54
|
+
status: ConversationStatus;
|
|
55
|
+
updated_at: string;
|
|
56
|
+
chat_last_message_at: string | null;
|
|
57
|
+
latest_turn_status: "running" | "completed" | "failed" | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type OpenPlanThreadListItem = {
|
|
61
|
+
id: string;
|
|
62
|
+
status: ConversationStatus;
|
|
63
|
+
created_at: string;
|
|
64
|
+
updated_at: string;
|
|
65
|
+
task_id: string | null;
|
|
66
|
+
latest_plan_assistant_message: string | null;
|
|
67
|
+
first_user_message: string | null;
|
|
68
|
+
has_completed_plan_turn: boolean;
|
|
69
|
+
latest_turn_status: "running" | "completed" | "failed" | null;
|
|
70
|
+
task_status: string | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ConversationTurn = {
|
|
74
|
+
id: string;
|
|
75
|
+
thread_id: string;
|
|
76
|
+
mode: ConversationMode;
|
|
77
|
+
user_message: string;
|
|
78
|
+
assistant_message: string | null;
|
|
79
|
+
reasoning_effort: ReasoningEffort | null;
|
|
80
|
+
requested_recipient_agent_id: string | null;
|
|
81
|
+
selected_agent_id: string | null;
|
|
82
|
+
orchestrator_agent_id: string | null;
|
|
83
|
+
worker_session_id: string | null;
|
|
84
|
+
selection_status: "auto_selected" | "manual_selected" | "recipient_required" | "invalid" | null;
|
|
85
|
+
status: "running" | "completed" | "failed";
|
|
86
|
+
created_at: string;
|
|
87
|
+
completed_at: string | null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type ConversationMessage = {
|
|
91
|
+
id: string;
|
|
92
|
+
thread_id: string;
|
|
93
|
+
turn_id: string | null;
|
|
94
|
+
role: "user" | "assistant" | "system";
|
|
95
|
+
content: string;
|
|
96
|
+
created_at: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type ConversationEvent = {
|
|
100
|
+
id: string;
|
|
101
|
+
seq?: number;
|
|
102
|
+
thread_id: string;
|
|
103
|
+
turn_id: string | null;
|
|
104
|
+
event_name: string;
|
|
105
|
+
payload_json: string;
|
|
106
|
+
created_at: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type AdapterEvent =
|
|
110
|
+
| { type: "assistant_text"; delta: string }
|
|
111
|
+
| { type: "tool_event"; name: string; payload: Record<string, unknown> | string | null };
|
|
112
|
+
|
|
113
|
+
export type AdapterSendTurnInput = {
|
|
114
|
+
prompt: string;
|
|
115
|
+
modelId: string;
|
|
116
|
+
sessionId: string | null;
|
|
117
|
+
cwd: string;
|
|
118
|
+
env?: Record<string, string>;
|
|
119
|
+
mode: ConversationMode;
|
|
120
|
+
reasoningEffort?: ReasoningEffort;
|
|
121
|
+
onEvent: (event: AdapterEvent) => void | Promise<void>;
|
|
122
|
+
abortSignal?: AbortSignal;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type AdapterSendTurnResult = {
|
|
126
|
+
sessionId: string;
|
|
127
|
+
finalText: string;
|
|
128
|
+
rawSummary?: string;
|
|
129
|
+
aborted?: boolean;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export interface ConversationAdapter {
|
|
133
|
+
sendTurn(input: AdapterSendTurnInput): Promise<AdapterSendTurnResult>;
|
|
134
|
+
}
|