@truefoundry/trueforge-assistant-ui-runtime 0.0.0 → 0.2.0-rc.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +201 -0
  3. package/README.md +146 -4
  4. package/dist/chunk-2SQK6TIO.js +104 -0
  5. package/dist/chunk-2SQK6TIO.js.map +1 -0
  6. package/dist/index.d.ts +378 -0
  7. package/dist/index.js +4391 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/server/index.d.ts +1210 -0
  10. package/dist/server/index.js +9 -0
  11. package/dist/server/index.js.map +1 -0
  12. package/package.json +79 -16
  13. package/src/askUserQuestion.ts +38 -0
  14. package/src/attachmentAdapter.ts +63 -0
  15. package/src/collectPending.ts +167 -0
  16. package/src/constants.ts +2 -0
  17. package/src/convertTurnMessages.ts +1679 -0
  18. package/src/createSubAgent.ts +11 -0
  19. package/src/draft/agentSpec.ts +34 -0
  20. package/src/draft/draftSessionBridge.ts +28 -0
  21. package/src/draft/trueforgeDraftThreadListAdapter.ts +73 -0
  22. package/src/draft/useDraftAgentSpec.ts +289 -0
  23. package/src/extractTurnUserText.ts +23 -0
  24. package/src/foldPeerThreads.ts +553 -0
  25. package/src/hooks.ts +176 -0
  26. package/src/index.ts +227 -0
  27. package/src/lastUserMessageText.ts +19 -0
  28. package/src/listPages.ts +19 -0
  29. package/src/loadSessionSnapshot.ts +34 -0
  30. package/src/mcpAuth.ts +35 -0
  31. package/src/messageCustomMetadata.ts +50 -0
  32. package/src/modelMessageContent.ts +149 -0
  33. package/src/modelMessageImageContent.ts +154 -0
  34. package/src/requiredActionInputs.ts +38 -0
  35. package/src/sandboxDownload.ts +33 -0
  36. package/src/server/eventUtils.ts +125 -0
  37. package/src/server/events.ts +232 -0
  38. package/src/server/index.ts +178 -0
  39. package/src/server/types.ts +1191 -0
  40. package/src/sessionListStartTimestamp.ts +6 -0
  41. package/src/sessionSnapshot.ts +146 -0
  42. package/src/sessionThreadMetadata.ts +36 -0
  43. package/src/sessions.ts +17 -0
  44. package/src/streamTurn.ts +118 -0
  45. package/src/toolApproval.ts +413 -0
  46. package/src/toolResponse.ts +346 -0
  47. package/src/trueforgeExtras.ts +223 -0
  48. package/src/trueforgeOwnedSessionsThreadListAdapter.ts +71 -0
  49. package/src/trueforgeThreadListAdapter.ts +69 -0
  50. package/src/turnEventHelpers.ts +71 -0
  51. package/src/turnStreamUpdate.ts +11 -0
  52. package/src/types.ts +84 -0
  53. package/src/useTrueForgeAgentMessages.ts +1138 -0
  54. package/src/useTrueForgeAgentRuntime.ts +308 -0
  55. package/index.js +0 -6
@@ -0,0 +1,149 @@
1
+ import type { ThreadAssistantMessagePart } from '@assistant-ui/core';
2
+ import type { PendingResponseRef } from './foldPeerThreads.js';
3
+ import { extractImagePartsFromModelMessage } from './modelMessageImageContent.js';
4
+ import type { ModelMessageEvent } from './server/index.js';
5
+
6
+ export type AssistantContentPart = ThreadAssistantMessagePart;
7
+
8
+ export interface ToolCallContext {
9
+ toolResults?: ReadonlyMap<string, string>;
10
+ pendingApprovals?: ReadonlyMap<string, { id: string }>;
11
+ approvalDecisions?: ReadonlyMap<string, { id: string; approved: boolean; reason?: string }>;
12
+ pendingResponses?: ReadonlyMap<string, PendingResponseRef>;
13
+ }
14
+
15
+ export type SdkToolCall = NonNullable<ModelMessageEvent['toolCalls']>[number];
16
+
17
+ type ToolCallPart = Extract<AssistantContentPart, { type: 'tool-call' }>;
18
+ type ToolCallArgValue = ToolCallPart['args'][string];
19
+
20
+ function isToolCallArgValue(value: unknown): value is ToolCallArgValue {
21
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
22
+ return true;
23
+ }
24
+ if (typeof value === 'number') {
25
+ return Number.isFinite(value);
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return value.every(isToolCallArgValue);
29
+ }
30
+ if (value != null && typeof value === 'object') {
31
+ return Object.keys(value).every(key => isToolCallArgValue(Reflect.get(value, key)));
32
+ }
33
+ return false;
34
+ }
35
+
36
+ function parseToolArgs(argsText: string): ToolCallPart['args'] {
37
+ if (!argsText) {
38
+ return {};
39
+ }
40
+ try {
41
+ const parsed: unknown = JSON.parse(argsText);
42
+ if (parsed != null && typeof parsed === 'object' && !Array.isArray(parsed)) {
43
+ const entries: [string, ToolCallArgValue][] = [];
44
+ for (const key of Object.keys(parsed)) {
45
+ const value: unknown = Reflect.get(parsed, key);
46
+ if (isToolCallArgValue(value)) {
47
+ entries.push([key, value]);
48
+ }
49
+ }
50
+ return Object.fromEntries(entries);
51
+ }
52
+ return {};
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ function toolCallToPart(toolCall: SdkToolCall, context?: ToolCallContext): ToolCallPart {
59
+ const argsText = toolCall.function.arguments;
60
+ const toolResult = context?.toolResults?.get(toolCall.id);
61
+ const pendingResponse = context?.pendingResponses?.get(toolCall.id);
62
+ const pendingApproval = context?.pendingApprovals?.get(toolCall.id);
63
+ const approvalDecision = context?.approvalDecisions?.get(toolCall.id);
64
+
65
+ let interrupt: ToolCallPart['interrupt'];
66
+ if (pendingResponse != null && toolResult === undefined) {
67
+ interrupt = {
68
+ type: 'human',
69
+ payload: {
70
+ ...(pendingResponse.question != null ? { question: pendingResponse.question } : {}),
71
+ ...(pendingResponse.options != null ? { options: pendingResponse.options } : {}),
72
+ },
73
+ };
74
+ }
75
+
76
+ let approval: ToolCallPart['approval'];
77
+ if (approvalDecision != null) {
78
+ approval = {
79
+ id: approvalDecision.id,
80
+ approved: approvalDecision.approved,
81
+ ...(approvalDecision.reason != null ? { reason: approvalDecision.reason } : {}),
82
+ };
83
+ } else if (pendingApproval != null) {
84
+ approval = { id: pendingApproval.id };
85
+ }
86
+
87
+ let result: ToolCallPart['result'];
88
+ let isError = false;
89
+ if (toolResult !== undefined) {
90
+ result = toolResult;
91
+ } else if (approvalDecision?.approved === false) {
92
+ result = {
93
+ error:
94
+ approvalDecision.reason == null || approvalDecision.reason.length === 0
95
+ ? 'Tool approval denied'
96
+ : approvalDecision.reason,
97
+ };
98
+ isError = true;
99
+ }
100
+
101
+ return {
102
+ type: 'tool-call',
103
+ toolCallId: toolCall.id,
104
+ toolName: toolCall.function.name,
105
+ argsText,
106
+ args: parseToolArgs(argsText),
107
+ ...(result !== undefined ? { result } : {}),
108
+ ...(isError ? { isError: true } : {}),
109
+ ...(approval != null ? { approval } : {}),
110
+ ...(interrupt != null ? { interrupt } : {}),
111
+ };
112
+ }
113
+
114
+ function extractText(message: ModelMessageEvent): string {
115
+ const { content, refusal } = message;
116
+ if (content == null) {
117
+ return refusal ?? '';
118
+ }
119
+ if (typeof content === 'string') {
120
+ return content;
121
+ }
122
+ return content
123
+ .map(part => {
124
+ if (part.type === 'text') {
125
+ return part.text;
126
+ }
127
+ if (part.type === 'refusal') {
128
+ return part.refusal;
129
+ }
130
+ return '';
131
+ })
132
+ .join('');
133
+ }
134
+
135
+ export function buildAssistantContent(message: ModelMessageEvent, context?: ToolCallContext): AssistantContentPart[] {
136
+ const parts: AssistantContentPart[] = [];
137
+ if (message.reasoningContent) {
138
+ parts.push({ type: 'reasoning', text: message.reasoningContent });
139
+ }
140
+ const text = extractText(message);
141
+ if (text) {
142
+ parts.push({ type: 'text', text });
143
+ }
144
+ parts.push(...extractImagePartsFromModelMessage(message));
145
+ for (const toolCall of message.toolCalls ?? []) {
146
+ parts.push(toolCallToPart(toolCall, context));
147
+ }
148
+ return parts;
149
+ }
@@ -0,0 +1,154 @@
1
+ import type { CompleteAttachment } from '@assistant-ui/core';
2
+ import {
3
+ isEventDelta,
4
+ mergeEventDelta,
5
+ type ModelMessageDeltaEvent,
6
+ type ModelMessageEvent,
7
+ type TurnEvent,
8
+ type TurnStreamingEvent,
9
+ } from './server/index.js';
10
+
11
+ import type { AssistantContentPart } from './modelMessageContent.js';
12
+
13
+ export interface ImageUrlContentPart {
14
+ type: 'image_url';
15
+ image_url: { url: string };
16
+ }
17
+
18
+ type ModelMessageContentPart =
19
+ { type: 'text'; text: string } | { type: 'refusal'; refusal: string } | ImageUrlContentPart;
20
+
21
+ type ContentBlockDelta = NonNullable<ModelMessageDeltaEvent['contentBlocks']>[number];
22
+
23
+ function parseDataUriMime(data: string): string {
24
+ if (!data.startsWith('data:')) {
25
+ return 'image/png';
26
+ }
27
+ const match = /^data:([^;,]+)/.exec(data);
28
+ return match?.[1] ?? 'image/png';
29
+ }
30
+
31
+ function imageFilenameFromUrl(url: string, index: number): string {
32
+ const mimeType = parseDataUriMime(url);
33
+ const ext = mimeType.split('/')[1] ?? 'png';
34
+ return `image-${String(index + 1)}.${ext}`;
35
+ }
36
+
37
+ export function isImageUrlContentPart(part: unknown): part is ImageUrlContentPart {
38
+ if (!isUnknownRecord(part) || part['type'] !== 'image_url' || !isUnknownRecord(part['image_url'])) {
39
+ return false;
40
+ }
41
+ return typeof part['image_url']['url'] === 'string';
42
+ }
43
+
44
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
45
+ return value != null && typeof value === 'object' && !Array.isArray(value);
46
+ }
47
+
48
+ function normalizeModelMessageContent(message: ModelMessageEvent): ModelMessageContentPart[] {
49
+ const { content } = message;
50
+ if (content == null) {
51
+ return [];
52
+ }
53
+ if (typeof content === 'string') {
54
+ return content.length > 0 ? [{ type: 'text', text: content }] : [];
55
+ }
56
+ return content;
57
+ }
58
+
59
+ function mergeContentBlockDeltas(message: ModelMessageEvent, blocks: readonly ContentBlockDelta[]): void {
60
+ const content = normalizeModelMessageContent(message);
61
+ message.content = content;
62
+
63
+ for (const block of blocks) {
64
+ const index = block.index;
65
+ while (content.length <= index) {
66
+ content.push({ type: 'text', text: '' });
67
+ }
68
+
69
+ const delta = block.delta;
70
+ if (delta.type === 'text') {
71
+ const existing = content[index];
72
+ if (existing?.type === 'text') {
73
+ existing.text += delta.text ?? '';
74
+ } else {
75
+ content[index] = { type: 'text', text: delta.text ?? '' };
76
+ }
77
+ continue;
78
+ }
79
+
80
+ const chunk = delta.image_url?.url ?? '';
81
+ const existing = content[index];
82
+ if (isImageUrlContentPart(existing)) {
83
+ existing.image_url.url += chunk;
84
+ } else {
85
+ content[index] = { type: 'image_url', image_url: { url: chunk } };
86
+ }
87
+ }
88
+ }
89
+
90
+ export function mergeStreamEventDelta(base: TurnEvent, delta: TurnStreamingEvent): void {
91
+ if (!isEventDelta(delta)) {
92
+ return;
93
+ }
94
+
95
+ mergeEventDelta(base, delta);
96
+
97
+ if (base.type !== 'model.message') {
98
+ return;
99
+ }
100
+
101
+ const blocks = delta.contentBlocks ?? delta.content_blocks;
102
+ if (blocks == null || blocks.length === 0) {
103
+ return;
104
+ }
105
+
106
+ mergeContentBlockDeltas(base, blocks);
107
+ }
108
+
109
+ export function imageUrlToAttachment(url: string, attachmentId: string): CompleteAttachment {
110
+ const mimeType = parseDataUriMime(url);
111
+ return {
112
+ id: attachmentId,
113
+ type: 'image',
114
+ name: imageFilenameFromUrl(url, 0),
115
+ contentType: mimeType,
116
+ status: { type: 'complete' },
117
+ content: [{ type: 'image', image: url, filename: imageFilenameFromUrl(url, 0) }],
118
+ };
119
+ }
120
+
121
+ export function imagePartToAssistantImage(url: string, index: number): AssistantContentPart {
122
+ return {
123
+ type: 'image',
124
+ image: url,
125
+ filename: imageFilenameFromUrl(url, index),
126
+ };
127
+ }
128
+
129
+ export function extractImagePartsFromModelMessage(message: ModelMessageEvent): AssistantContentPart[] {
130
+ const parts: AssistantContentPart[] = [];
131
+ let imageIndex = 0;
132
+
133
+ for (const part of normalizeModelMessageContent(message)) {
134
+ if (!isImageUrlContentPart(part)) {
135
+ continue;
136
+ }
137
+ const url = part.image_url.url.trim();
138
+ if (url.length === 0) {
139
+ continue;
140
+ }
141
+ parts.push(imagePartToAssistantImage(url, imageIndex));
142
+ imageIndex += 1;
143
+ }
144
+
145
+ return parts;
146
+ }
147
+
148
+ export function extractImageUrlFromUserContentItem(part: unknown): string | undefined {
149
+ if (isImageUrlContentPart(part)) {
150
+ const url = part.image_url.url.trim();
151
+ return url.length > 0 ? url : undefined;
152
+ }
153
+ return undefined;
154
+ }
@@ -0,0 +1,38 @@
1
+ import type { ThreadMessage } from '@assistant-ui/core';
2
+ import type { TurnInputItem, UserToolApprovalEvent, UserToolResponseEvent } from './server/index.js';
3
+
4
+ import { ROOT_THREAD_ID } from './constants.js';
5
+ import { collectApprovalInputs, messageHasPendingApprovals } from './toolApproval.js';
6
+ import { collectResponseInputs, messageHasPendingResponses } from './toolResponse.js';
7
+
8
+ export type RequiredActionInput = Extract<TurnInputItem, UserToolApprovalEvent | UserToolResponseEvent>;
9
+
10
+ export function messageHasPendingRequiredActions(message: ThreadMessage | undefined): boolean {
11
+ return messageHasPendingApprovals(message) || messageHasPendingResponses(message);
12
+ }
13
+
14
+ export function collectRequiredActionInputs(
15
+ message: ThreadMessage,
16
+ defaultThreadId: string = ROOT_THREAD_ID,
17
+ ): RequiredActionInput[] {
18
+ if (messageHasPendingRequiredActions(message)) {
19
+ return [];
20
+ }
21
+ return [...collectApprovalInputs(message, defaultThreadId), ...collectResponseInputs(message, defaultThreadId)];
22
+ }
23
+
24
+ export function isRequiredActionInput(item: TurnInputItem): item is RequiredActionInput {
25
+ return item.type === 'user.tool_approval' || item.type === 'user.tool_response';
26
+ }
27
+
28
+ export function findPausedAssistantMessage(
29
+ messages: readonly ThreadMessage[],
30
+ ): Extract<ThreadMessage, { role: 'assistant' }> | undefined {
31
+ for (let i = messages.length - 1; i >= 0; i--) {
32
+ const candidate = messages[i];
33
+ if (candidate?.role === 'assistant' && candidate.status.type === 'requires-action') {
34
+ return candidate;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Request shape for {@link AgentChatServer.downloadSandboxFile}.
3
+ * Turn-scoped hosts resolve the sandbox from `turnId` and may omit `sandboxId`;
4
+ * hosts that address sandboxes directly still receive `sandboxId` when known.
5
+ */
6
+ export interface SandboxDownloadRequest {
7
+ sessionId: string;
8
+ turnId: string;
9
+ path: string;
10
+ sandboxId?: string;
11
+ }
12
+
13
+ /**
14
+ * Builds the host download request. Requires a saved session and a turn scope;
15
+ * does not require `sandboxId` (it may be missing after resume when
16
+ * `sandbox.created` fell outside the loaded history window).
17
+ */
18
+ export function buildSandboxDownloadRequest(args: {
19
+ sessionId: string | undefined;
20
+ turnId: string;
21
+ path: string;
22
+ sandboxId?: string;
23
+ }): SandboxDownloadRequest {
24
+ if (args.sessionId == null) {
25
+ throw new Error('This session has not been saved yet, so its files cannot be downloaded.');
26
+ }
27
+ return {
28
+ sessionId: args.sessionId,
29
+ turnId: args.turnId,
30
+ path: args.path,
31
+ ...(args.sandboxId != null ? { sandboxId: args.sandboxId } : {}),
32
+ };
33
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Local implementations of streaming delta helpers.
3
+ * Formerly imported from trueforge-gateway-sdk/agents.
4
+ */
5
+
6
+ import type {
7
+ DeltaEvents,
8
+ ModelMessageDeltaEvent,
9
+ ModelMessageEvent,
10
+ ToolCall,
11
+ ToolInfo,
12
+ TurnEvent,
13
+ TurnStreamingEvent,
14
+ } from './events.js';
15
+
16
+ /** True for `.delta` streaming events. */
17
+ export function isEventDelta(event: TurnStreamingEvent): event is DeltaEvents {
18
+ return typeof event.type === 'string' && event.type.endsWith('.delta');
19
+ }
20
+
21
+ /**
22
+ * Merge `delta` into `base` in place (same `id` required).
23
+ * Currently handles `model.message.delta` → `model.message`.
24
+ */
25
+ export function mergeEventDelta(base: TurnEvent, delta: DeltaEvents): void {
26
+ if (base.id !== delta.id) {
27
+ throw new Error(`Cannot merge delta into a different event: base id "${base.id}" != delta id "${delta.id}".`);
28
+ }
29
+ if (base.type === 'model.message') {
30
+ mergeModelMessageDelta(base, delta);
31
+ }
32
+ }
33
+
34
+ function asToolInfo(value: unknown): ToolInfo | undefined {
35
+ if (value == null || typeof value !== 'object') {
36
+ return undefined;
37
+ }
38
+ const type: unknown = Reflect.get(value, 'type');
39
+ if (typeof type !== 'string') {
40
+ return undefined;
41
+ }
42
+ const name: unknown = Reflect.get(value, 'name');
43
+ if (type === 'trueforge-system') {
44
+ return typeof name === 'string' ? { type, name } : undefined;
45
+ }
46
+ if (type === 'mcp') {
47
+ const serverId: unknown = Reflect.get(value, 'serverId');
48
+ const serverName: unknown = Reflect.get(value, 'serverName');
49
+ return typeof serverId === 'string' && typeof serverName === 'string' && typeof name === 'string'
50
+ ? { type, serverId, serverName, name }
51
+ : undefined;
52
+ }
53
+ return typeof name === 'string' ? { type, name } : { type };
54
+ }
55
+
56
+ function mergeModelMessageDelta(base: ModelMessageEvent, delta: ModelMessageDeltaEvent): void {
57
+ if (delta.content) {
58
+ if (base.content === undefined || base.content === null || typeof base.content === 'string') {
59
+ base.content = (base.content ?? '') + delta.content;
60
+ } else {
61
+ const last = base.content[base.content.length - 1];
62
+ if (last?.type === 'text') {
63
+ last.text += delta.content;
64
+ } else {
65
+ base.content.push({ type: 'text', text: delta.content });
66
+ }
67
+ }
68
+ }
69
+
70
+ if (delta.refusal) {
71
+ base.refusal = (base.refusal ?? '') + delta.refusal;
72
+ }
73
+
74
+ if (delta.toolCalls) {
75
+ base.toolCalls ??= [];
76
+ for (const d of delta.toolCalls) {
77
+ let tc: ToolCall | undefined = base.toolCalls[d.index];
78
+ if (tc === undefined) {
79
+ const toolInfo = asToolInfo(d.toolInfo);
80
+ tc = {
81
+ id: d.id ?? '',
82
+ type: d.type ?? 'function',
83
+ function: {
84
+ name: d.function?.name ?? '',
85
+ arguments: '',
86
+ },
87
+ ...(toolInfo != null ? { toolInfo } : {}),
88
+ };
89
+ base.toolCalls[d.index] = tc;
90
+ }
91
+ if (d.id) {
92
+ tc.id = d.id;
93
+ }
94
+ if (d.type) {
95
+ tc.type = d.type;
96
+ }
97
+ if (d.function?.name) {
98
+ tc.function.name = d.function.name;
99
+ }
100
+ if (d.function?.arguments) {
101
+ tc.function.arguments += d.function.arguments;
102
+ }
103
+ const toolInfo = asToolInfo(d.toolInfo);
104
+ if (toolInfo != null) {
105
+ tc.toolInfo = toolInfo;
106
+ }
107
+ if (d.providerSpecificFields) {
108
+ tc.providerSpecificFields = {
109
+ ...(tc.providerSpecificFields ?? {}),
110
+ ...d.providerSpecificFields,
111
+ };
112
+ }
113
+ }
114
+ }
115
+
116
+ if (delta.finishReason) {
117
+ base.finishReason = delta.finishReason;
118
+ }
119
+ if (delta.reasoningContent) {
120
+ base.reasoningContent = (base.reasoningContent ?? '') + delta.reasoningContent;
121
+ }
122
+ if (delta.usage) {
123
+ base.usage = delta.usage;
124
+ }
125
+ }