@truefoundry/assistant-ui-runtime 0.1.0-rc.1
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 +567 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +4440 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/src/agentSessionModule.d.ts +25 -0
- package/src/agentSpec.ts +44 -0
- package/src/askUserQuestion.ts +37 -0
- package/src/attachmentAdapter.test.ts +56 -0
- package/src/attachmentAdapter.ts +58 -0
- package/src/bindDraftAgentSession.test.ts +55 -0
- package/src/bindDraftAgentSession.ts +66 -0
- package/src/buildEditedUserMessageContent.test.ts +61 -0
- package/src/collectPending.test.ts +69 -0
- package/src/collectPending.ts +165 -0
- package/src/constants.ts +2 -0
- package/src/convertTurnMessages.test.ts +1991 -0
- package/src/convertTurnMessages.ts +1251 -0
- package/src/createSubAgent.ts +8 -0
- package/src/draftAgentConfig.test.ts +88 -0
- package/src/draftSessionBridge.ts +28 -0
- package/src/extractTurnUserText.ts +21 -0
- package/src/foldPeerThreads.test.ts +386 -0
- package/src/foldPeerThreads.ts +587 -0
- package/src/hooks.ts +123 -0
- package/src/index.ts +68 -0
- package/src/lastUserMessageText.ts +19 -0
- package/src/loadSessionSnapshot.test.ts +57 -0
- package/src/loadSessionSnapshot.ts +35 -0
- package/src/mcpAuth.ts +44 -0
- package/src/messageCustomMetadata.ts +37 -0
- package/src/modelMessageContent.ts +135 -0
- package/src/modelMessageImageContent.test.ts +109 -0
- package/src/modelMessageImageContent.ts +193 -0
- package/src/requiredActionInputs.test.ts +394 -0
- package/src/requiredActionInputs.ts +65 -0
- package/src/requiredActionsFromActiveUpdate.test.ts +95 -0
- package/src/sessionListStartTimestamp.ts +6 -0
- package/src/sessionSnapshot.ts +107 -0
- package/src/sessions.ts +36 -0
- package/src/streamTurn.test.ts +243 -0
- package/src/streamTurn.ts +119 -0
- package/src/toolApproval.test.ts +137 -0
- package/src/toolApproval.ts +459 -0
- package/src/toolResponse.test.ts +136 -0
- package/src/toolResponse.ts +427 -0
- package/src/truefoundryDraftThreadListAdapter.test.ts +140 -0
- package/src/truefoundryDraftThreadListAdapter.ts +63 -0
- package/src/truefoundryExtras.ts +45 -0
- package/src/truefoundryThreadListAdapter.test.ts +103 -0
- package/src/truefoundryThreadListAdapter.ts +59 -0
- package/src/turnEventHelpers.ts +98 -0
- package/src/turnStreamUpdate.ts +11 -0
- package/src/types.ts +92 -0
- package/src/useDraftAgentSpec.ts +173 -0
- package/src/useTrueFoundryAgentMessages.test.tsx +723 -0
- package/src/useTrueFoundryAgentMessages.ts +757 -0
- package/src/useTrueFoundryAgentRuntime.ts +268 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ROOT_THREAD_ID } from "./constants.js";
|
|
4
|
+
import { toolApprovalStatus, TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY } from "./toolApproval.js";
|
|
5
|
+
import { toolResponseStatus, TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY } from "./toolResponse.js";
|
|
6
|
+
import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
|
|
7
|
+
import { requiredActionsFromActiveUpdate } from "./useTrueFoundryAgentMessages.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression coverage for the live-stream pause path: when a turn pauses at a
|
|
11
|
+
* tool approval or an `ask_user_question` (`tool.response_required`),
|
|
12
|
+
* `commitActiveStream` fabricates a synthetic `TurnStateDone`. Its
|
|
13
|
+
* `requiredActions` are reconstructed here from the paused update. If they were
|
|
14
|
+
* dropped (as they were before the fix), the committed assistant message would
|
|
15
|
+
* lose its `requires-action` status and the resume turn would never be sent.
|
|
16
|
+
*/
|
|
17
|
+
describe("requiredActionsFromActiveUpdate", () => {
|
|
18
|
+
it("reconstructs a tool.response_required pause (ask_user_question)", () => {
|
|
19
|
+
const update: TurnStreamUpdate = {
|
|
20
|
+
content: [],
|
|
21
|
+
status: toolResponseStatus(),
|
|
22
|
+
metadata: {
|
|
23
|
+
custom: { [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
expect(requiredActionsFromActiveUpdate(update)).toEqual([
|
|
28
|
+
expect.objectContaining({
|
|
29
|
+
type: "tool.response_required",
|
|
30
|
+
threadId: ROOT_THREAD_ID,
|
|
31
|
+
toolCalls: [],
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reconstructs a tool.approval_required pause", () => {
|
|
37
|
+
const update: TurnStreamUpdate = {
|
|
38
|
+
content: [],
|
|
39
|
+
status: toolApprovalStatus(),
|
|
40
|
+
metadata: {
|
|
41
|
+
custom: { [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: "sub-agent-1" },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
expect(requiredActionsFromActiveUpdate(update)).toEqual([
|
|
46
|
+
expect.objectContaining({
|
|
47
|
+
type: "tool.approval_required",
|
|
48
|
+
threadId: "sub-agent-1",
|
|
49
|
+
toolCalls: [],
|
|
50
|
+
}),
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reconstructs both approval and response pauses in one turn", () => {
|
|
55
|
+
const update: TurnStreamUpdate = {
|
|
56
|
+
content: [],
|
|
57
|
+
status: toolApprovalStatus(),
|
|
58
|
+
metadata: {
|
|
59
|
+
custom: {
|
|
60
|
+
[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID,
|
|
61
|
+
[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: "sub-agent-1",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const actions = requiredActionsFromActiveUpdate(update);
|
|
67
|
+
expect(actions.map((action) => action.type)).toEqual([
|
|
68
|
+
"tool.approval_required",
|
|
69
|
+
"tool.response_required",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reconstructs an mcp.auth_required pause", () => {
|
|
74
|
+
const mcpServers = [{ id: "gh", name: "GitHub", authUrl: "https://auth" }];
|
|
75
|
+
const update: TurnStreamUpdate = {
|
|
76
|
+
content: [],
|
|
77
|
+
metadata: { custom: { pendingMcpAuth: true, mcpServers } },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
expect(requiredActionsFromActiveUpdate(update)).toEqual([
|
|
81
|
+
expect.objectContaining({ type: "mcp.auth_required", mcpServers }),
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns no actions for a completed (non-paused) update", () => {
|
|
86
|
+
const update: TurnStreamUpdate = {
|
|
87
|
+
content: [],
|
|
88
|
+
metadata: {
|
|
89
|
+
custom: { [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]: ROOT_THREAD_ID },
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
expect(requiredActionsFromActiveUpdate(update)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { McpAuthRequiredEvent, Turn, TurnInputItem } from "truefoundry-gateway-sdk/agents";
|
|
2
|
+
|
|
3
|
+
import { extractTurnUserText } from "./extractTurnUserText.js";
|
|
4
|
+
import { PeerThreadFoldState } from "./foldPeerThreads.js";
|
|
5
|
+
import type { StoredApprovalDecision } from "./toolApproval.js";
|
|
6
|
+
import type { StoredToolResponse } from "./toolResponse.js";
|
|
7
|
+
import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
|
|
8
|
+
|
|
9
|
+
/** Turn metadata retained for cross-turn projection and subsequent required-action replay. */
|
|
10
|
+
export type SessionTurnRecord = Pick<
|
|
11
|
+
Turn,
|
|
12
|
+
"id" | "createdAt" | "state" | "input"
|
|
13
|
+
> & {
|
|
14
|
+
/** Denormalized from `input` for user/assistant interleaving during projection. */
|
|
15
|
+
userText?: string;
|
|
16
|
+
/** Root-thread `model.message` ids ingested with this turn (for per-group projection). */
|
|
17
|
+
rootModelMessageIds?: readonly string[];
|
|
18
|
+
/** sandboxId observed via a `sandbox.created` event during this turn (session-scoped, latest wins). */
|
|
19
|
+
sandboxId?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type RequiredActionsOverlay = {
|
|
23
|
+
approvals: Map<string, StoredApprovalDecision>;
|
|
24
|
+
toolResponses: Map<string, StoredToolResponse>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ActiveStreamState = {
|
|
28
|
+
turnId: string;
|
|
29
|
+
update: TurnStreamUpdate;
|
|
30
|
+
isContinuation: boolean;
|
|
31
|
+
streamComplete?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type PendingUserMessage = {
|
|
35
|
+
turnId: string;
|
|
36
|
+
/** Gateway user.message content (text-only string or text/file parts). */
|
|
37
|
+
content: Extract<TurnInputItem, { type: "user.message" }>["content"];
|
|
38
|
+
createdAt: Date;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type SessionSnapshot = {
|
|
42
|
+
fold: PeerThreadFoldState;
|
|
43
|
+
turns: SessionTurnRecord[];
|
|
44
|
+
pendingMcpAuth?: McpAuthRequiredEvent;
|
|
45
|
+
pendingUser?: PendingUserMessage;
|
|
46
|
+
activeStream?: ActiveStreamState;
|
|
47
|
+
/** Root `model.message` ids present before the active turn group started (streaming scope). */
|
|
48
|
+
groupRootBaseline?: readonly string[];
|
|
49
|
+
requiredActions: RequiredActionsOverlay;
|
|
50
|
+
runningTurn?: Turn;
|
|
51
|
+
unstable_resume?: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ProjectSessionMessagesOptions = {
|
|
55
|
+
getCreatedAt?: (messageId: string, fallback: Date) => Date;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function emptyRequiredActionsOverlay(): RequiredActionsOverlay {
|
|
59
|
+
return {
|
|
60
|
+
approvals: new Map(),
|
|
61
|
+
toolResponses: new Map(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createEmptySessionSnapshot(): SessionSnapshot {
|
|
66
|
+
return {
|
|
67
|
+
fold: new PeerThreadFoldState(),
|
|
68
|
+
turns: [],
|
|
69
|
+
requiredActions: emptyRequiredActionsOverlay(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function turnToSessionRecord(turn: Turn): SessionTurnRecord {
|
|
74
|
+
const userText = extractTurnUserText(turn.input);
|
|
75
|
+
return {
|
|
76
|
+
id: turn.id,
|
|
77
|
+
...(userText ? { userText } : {}),
|
|
78
|
+
createdAt: turn.createdAt,
|
|
79
|
+
state: turn.state,
|
|
80
|
+
input: turn.input,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Returns a new snapshot wrapper; fold maps may be mutated in place before calling. */
|
|
85
|
+
export function replaceSessionSnapshot(
|
|
86
|
+
snapshot: SessionSnapshot,
|
|
87
|
+
patch: Partial<Omit<SessionSnapshot, "fold" | "requiredActions">> & {
|
|
88
|
+
requiredActions?: RequiredActionsOverlay;
|
|
89
|
+
},
|
|
90
|
+
): SessionSnapshot {
|
|
91
|
+
return {
|
|
92
|
+
...snapshot,
|
|
93
|
+
...patch,
|
|
94
|
+
...(patch.requiredActions != null
|
|
95
|
+
? { requiredActions: patch.requiredActions }
|
|
96
|
+
: {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function cloneRequiredActionsOverlay(
|
|
101
|
+
overlay: RequiredActionsOverlay,
|
|
102
|
+
): RequiredActionsOverlay {
|
|
103
|
+
return {
|
|
104
|
+
approvals: new Map(overlay.approvals),
|
|
105
|
+
toolResponses: new Map(overlay.toolResponses),
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSession,
|
|
3
|
+
AgentSessionClient,
|
|
4
|
+
} from "truefoundry-gateway-sdk/agents";
|
|
5
|
+
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
6
|
+
|
|
7
|
+
import { bindDraftAgentSession } from "./bindDraftAgentSession.js";
|
|
8
|
+
|
|
9
|
+
const inflightBySessionId = new Map<string, Promise<AgentSession>>();
|
|
10
|
+
|
|
11
|
+
export type GetSessionOptions = {
|
|
12
|
+
/** When set, validates the draft and binds turns to `/agents/sessions/{id}/turns`. */
|
|
13
|
+
draftGateway?: TrueFoundryGateway;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** `sessionId` is the assistant-ui thread `remoteId` from `RemoteThreadListAdapter.initialize`. */
|
|
17
|
+
export function getSession(
|
|
18
|
+
client: AgentSessionClient,
|
|
19
|
+
sessionId: string,
|
|
20
|
+
options?: GetSessionOptions,
|
|
21
|
+
): Promise<AgentSession> {
|
|
22
|
+
if (options?.draftGateway != null) {
|
|
23
|
+
return bindDraftAgentSession(client, options.draftGateway, sessionId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let inflight = inflightBySessionId.get(sessionId);
|
|
27
|
+
if (inflight == null) {
|
|
28
|
+
inflight = client.getSession({ sessionId }).finally(() => {
|
|
29
|
+
if (inflightBySessionId.get(sessionId) === inflight) {
|
|
30
|
+
inflightBySessionId.delete(sessionId);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
inflightBySessionId.set(sessionId, inflight);
|
|
34
|
+
}
|
|
35
|
+
return inflight;
|
|
36
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
AgentSession,
|
|
4
|
+
Turn,
|
|
5
|
+
TurnStreamData,
|
|
6
|
+
} from "truefoundry-gateway-sdk/agents";
|
|
7
|
+
|
|
8
|
+
import { ROOT_THREAD_ID } from "./constants.js";
|
|
9
|
+
import { PeerThreadFoldState } from "./foldPeerThreads.js";
|
|
10
|
+
import { resumeTurnStream, streamTurnContent } from "./streamTurn.js";
|
|
11
|
+
|
|
12
|
+
const createdAt = new Date().toISOString();
|
|
13
|
+
|
|
14
|
+
function streamData(
|
|
15
|
+
sequenceNumber: number,
|
|
16
|
+
event: TurnStreamData["event"],
|
|
17
|
+
): TurnStreamData {
|
|
18
|
+
return { sequenceNumber, event };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function collectUpdates(
|
|
22
|
+
generator: AsyncGenerator<{ content: unknown[] }>,
|
|
23
|
+
): Promise<{ content: unknown[] }[]> {
|
|
24
|
+
const updates: { content: unknown[] }[] = [];
|
|
25
|
+
for await (const update of generator) {
|
|
26
|
+
updates.push(update);
|
|
27
|
+
}
|
|
28
|
+
return updates;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("streamTurn", () => {
|
|
32
|
+
describe("streamTurnContent", () => {
|
|
33
|
+
it("prepares a user turn and yields folded stream updates", async () => {
|
|
34
|
+
const foldState = new PeerThreadFoldState();
|
|
35
|
+
const execute = vi.fn(() =>
|
|
36
|
+
(async function* () {
|
|
37
|
+
yield streamData(1, {
|
|
38
|
+
type: "model.message",
|
|
39
|
+
createdAt,
|
|
40
|
+
id: "m1",
|
|
41
|
+
threadId: ROOT_THREAD_ID,
|
|
42
|
+
content: "hello from stream",
|
|
43
|
+
});
|
|
44
|
+
})(),
|
|
45
|
+
);
|
|
46
|
+
const prepareTurn = vi.fn(() => ({ execute }));
|
|
47
|
+
const session = {
|
|
48
|
+
prepareTurn,
|
|
49
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
50
|
+
} as unknown as AgentSession;
|
|
51
|
+
|
|
52
|
+
const updates = await collectUpdates(
|
|
53
|
+
streamTurnContent(
|
|
54
|
+
session,
|
|
55
|
+
foldState,
|
|
56
|
+
{ userMessage: "hello" },
|
|
57
|
+
new AbortController().signal,
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(prepareTurn).toHaveBeenCalledWith({
|
|
62
|
+
input: [{ type: "user.message", content: "hello" }],
|
|
63
|
+
previousTurnId: "auto",
|
|
64
|
+
});
|
|
65
|
+
expect(updates).toEqual([
|
|
66
|
+
{ content: [{ type: "text", text: "hello from stream" }] },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("passes required-action inputs through prepareTurn", async () => {
|
|
71
|
+
const inputs = [
|
|
72
|
+
{
|
|
73
|
+
type: "user.tool_approval" as const,
|
|
74
|
+
threadId: ROOT_THREAD_ID,
|
|
75
|
+
toolCallId: "approval-1",
|
|
76
|
+
approval: { status: "allow" as const },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: "user.tool_response" as const,
|
|
80
|
+
threadId: ROOT_THREAD_ID,
|
|
81
|
+
toolCallId: "question-1",
|
|
82
|
+
content: "A",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
const execute = vi.fn(() => (async function* () {})());
|
|
86
|
+
const prepareTurn = vi.fn(() => ({ execute }));
|
|
87
|
+
const session = {
|
|
88
|
+
prepareTurn,
|
|
89
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
90
|
+
} as unknown as AgentSession;
|
|
91
|
+
|
|
92
|
+
await collectUpdates(
|
|
93
|
+
streamTurnContent(
|
|
94
|
+
session,
|
|
95
|
+
new PeerThreadFoldState(),
|
|
96
|
+
{ inputs },
|
|
97
|
+
new AbortController().signal,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(prepareTurn).toHaveBeenCalledWith({
|
|
102
|
+
input: inputs,
|
|
103
|
+
previousTurnId: "auto",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("uses empty input when resuming after MCP auth", async () => {
|
|
108
|
+
const execute = vi.fn(() => (async function* () {})());
|
|
109
|
+
const prepareTurn = vi.fn(() => ({ execute }));
|
|
110
|
+
const session = {
|
|
111
|
+
prepareTurn,
|
|
112
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
113
|
+
} as unknown as AgentSession;
|
|
114
|
+
|
|
115
|
+
await collectUpdates(
|
|
116
|
+
streamTurnContent(
|
|
117
|
+
session,
|
|
118
|
+
new PeerThreadFoldState(),
|
|
119
|
+
{ resumeMcpAuth: true },
|
|
120
|
+
new AbortController().signal,
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(prepareTurn).toHaveBeenCalledWith({
|
|
125
|
+
input: [],
|
|
126
|
+
previousTurnId: "auto",
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("forwards an explicit previousTurnId when branching", async () => {
|
|
131
|
+
const execute = vi.fn(() => (async function* () {})());
|
|
132
|
+
const prepareTurn = vi.fn(() => ({ execute }));
|
|
133
|
+
const session = {
|
|
134
|
+
prepareTurn,
|
|
135
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
136
|
+
} as unknown as AgentSession;
|
|
137
|
+
|
|
138
|
+
await collectUpdates(
|
|
139
|
+
streamTurnContent(
|
|
140
|
+
session,
|
|
141
|
+
new PeerThreadFoldState(),
|
|
142
|
+
{ userMessage: "edited", previousTurnId: "turn-a" },
|
|
143
|
+
new AbortController().signal,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(prepareTurn).toHaveBeenCalledWith({
|
|
148
|
+
input: [{ type: "user.message", content: "edited" }],
|
|
149
|
+
previousTurnId: "turn-a",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("omits previousTurnId when branching from root", async () => {
|
|
154
|
+
const execute = vi.fn(() => (async function* () {})());
|
|
155
|
+
const prepareTurn = vi.fn(() => ({ execute }));
|
|
156
|
+
const session = {
|
|
157
|
+
prepareTurn,
|
|
158
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
159
|
+
} as unknown as AgentSession;
|
|
160
|
+
|
|
161
|
+
await collectUpdates(
|
|
162
|
+
streamTurnContent(
|
|
163
|
+
session,
|
|
164
|
+
new PeerThreadFoldState(),
|
|
165
|
+
{ userMessage: "first", previousTurnId: null },
|
|
166
|
+
new AbortController().signal,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(prepareTurn).toHaveBeenCalledWith({
|
|
171
|
+
input: [{ type: "user.message", content: "first" }],
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns early and cancels the session when already aborted", async () => {
|
|
176
|
+
const execute = vi.fn(() => (async function* () {})());
|
|
177
|
+
const prepareTurn = vi.fn(() => ({ execute }));
|
|
178
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
179
|
+
const session = { prepareTurn, cancel } as unknown as AgentSession;
|
|
180
|
+
const abortController = new AbortController();
|
|
181
|
+
abortController.abort();
|
|
182
|
+
|
|
183
|
+
const updates = await collectUpdates(
|
|
184
|
+
streamTurnContent(
|
|
185
|
+
session,
|
|
186
|
+
new PeerThreadFoldState(),
|
|
187
|
+
{ userMessage: "hello" },
|
|
188
|
+
abortController.signal,
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(cancel).toHaveBeenCalled();
|
|
193
|
+
expect(execute).not.toHaveBeenCalled();
|
|
194
|
+
expect(updates).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("resumeTurnStream", () => {
|
|
199
|
+
it("reconnects with afterSequenceNumber and yields updates", async () => {
|
|
200
|
+
const foldState = new PeerThreadFoldState();
|
|
201
|
+
const stream = vi.fn(() =>
|
|
202
|
+
(async function* () {
|
|
203
|
+
yield streamData(2, {
|
|
204
|
+
type: "model.message",
|
|
205
|
+
createdAt,
|
|
206
|
+
id: "m2",
|
|
207
|
+
threadId: ROOT_THREAD_ID,
|
|
208
|
+
content: "resumed",
|
|
209
|
+
});
|
|
210
|
+
})(),
|
|
211
|
+
);
|
|
212
|
+
const turn = {
|
|
213
|
+
stream,
|
|
214
|
+
session: { cancel: vi.fn().mockResolvedValue(undefined) },
|
|
215
|
+
} as unknown as Turn;
|
|
216
|
+
|
|
217
|
+
const updates = await collectUpdates(
|
|
218
|
+
resumeTurnStream(turn, foldState, new AbortController().signal, 1),
|
|
219
|
+
);
|
|
220
|
+
expect(stream).toHaveBeenCalledWith({ afterSequenceNumber: 1 }, expect.any(Object));
|
|
221
|
+
expect(updates).toEqual([{ content: [{ type: "text", text: "resumed" }] }]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("returns early when aborted before streaming starts", async () => {
|
|
225
|
+
const stream = vi.fn(() => (async function* () {})());
|
|
226
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
227
|
+
const turn = {
|
|
228
|
+
stream,
|
|
229
|
+
session: { cancel },
|
|
230
|
+
} as unknown as Turn;
|
|
231
|
+
const abortController = new AbortController();
|
|
232
|
+
abortController.abort();
|
|
233
|
+
|
|
234
|
+
const updates = await collectUpdates(
|
|
235
|
+
resumeTurnStream(turn, new PeerThreadFoldState(), abortController.signal),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(cancel).toHaveBeenCalled();
|
|
239
|
+
expect(stream).not.toHaveBeenCalled();
|
|
240
|
+
expect(updates).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentSession,
|
|
3
|
+
type Turn,
|
|
4
|
+
type TurnInputItem,
|
|
5
|
+
} from "truefoundry-gateway-sdk/agents";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
streamTurnEvents,
|
|
9
|
+
type UserMessageContent,
|
|
10
|
+
} from "./convertTurnMessages.js";
|
|
11
|
+
import { PeerThreadFoldState } from "./foldPeerThreads.js";
|
|
12
|
+
import type { RequiredActionInput } from "./requiredActionInputs.js";
|
|
13
|
+
import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
|
|
14
|
+
|
|
15
|
+
export type StreamTurnOptions = {
|
|
16
|
+
userMessage?: UserMessageContent;
|
|
17
|
+
resumeMcpAuth?: boolean;
|
|
18
|
+
inputs?: RequiredActionInput[];
|
|
19
|
+
/**
|
|
20
|
+
* Branch anchor for `prepareTurn`. Omit for `"auto"`. Pass `null` for a fresh
|
|
21
|
+
* root turn (no `previousTurnId` field).
|
|
22
|
+
*/
|
|
23
|
+
previousTurnId?: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function buildTurnInput(options: StreamTurnOptions): TurnInputItem[] {
|
|
27
|
+
if (options.inputs != null) {
|
|
28
|
+
return options.inputs;
|
|
29
|
+
}
|
|
30
|
+
if (options.resumeMcpAuth === true) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
return [{ type: "user.message", content: options.userMessage ?? "" }];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bindAbort(session: AgentSession, abortSignal: AbortSignal): () => void {
|
|
37
|
+
const onAbort = () => {
|
|
38
|
+
void session.cancel().catch(() => undefined);
|
|
39
|
+
};
|
|
40
|
+
if (abortSignal.aborted) {
|
|
41
|
+
onAbort();
|
|
42
|
+
return onAbort;
|
|
43
|
+
}
|
|
44
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
45
|
+
return onAbort;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function* streamTurnContent(
|
|
49
|
+
session: AgentSession,
|
|
50
|
+
foldState: PeerThreadFoldState,
|
|
51
|
+
options: StreamTurnOptions,
|
|
52
|
+
abortSignal: AbortSignal,
|
|
53
|
+
groupRootBaseline?: readonly string[],
|
|
54
|
+
): AsyncGenerator<TurnStreamUpdate> {
|
|
55
|
+
const previousTurnId =
|
|
56
|
+
options.previousTurnId === null
|
|
57
|
+
? undefined
|
|
58
|
+
: (options.previousTurnId ?? "auto");
|
|
59
|
+
const turn = session.prepareTurn({
|
|
60
|
+
input: buildTurnInput(options),
|
|
61
|
+
...(previousTurnId != null ? { previousTurnId } : {}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const onAbort = bindAbort(session, abortSignal);
|
|
65
|
+
if (abortSignal.aborted) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
yield* streamTurnEvents(
|
|
71
|
+
turn.execute({ stream: true }, { abortSignal }),
|
|
72
|
+
foldState,
|
|
73
|
+
groupRootBaseline,
|
|
74
|
+
);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
} finally {
|
|
81
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** TODO: wire `afterSequenceNumber` from the last ingested stream event to skip replay on reconnect. */
|
|
86
|
+
export async function* resumeTurnStream(
|
|
87
|
+
turn: Turn,
|
|
88
|
+
foldState: PeerThreadFoldState,
|
|
89
|
+
abortSignal: AbortSignal,
|
|
90
|
+
afterSequenceNumber?: number,
|
|
91
|
+
groupRootBaseline?: readonly string[],
|
|
92
|
+
): AsyncGenerator<TurnStreamUpdate> {
|
|
93
|
+
const onAbort = () => {
|
|
94
|
+
void turn.session.cancel().catch(() => undefined);
|
|
95
|
+
};
|
|
96
|
+
if (abortSignal.aborted) {
|
|
97
|
+
onAbort();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
yield* streamTurnEvents(
|
|
104
|
+
turn.stream(
|
|
105
|
+
afterSequenceNumber != null ? { afterSequenceNumber } : {},
|
|
106
|
+
{ abortSignal },
|
|
107
|
+
),
|
|
108
|
+
foldState,
|
|
109
|
+
groupRootBaseline,
|
|
110
|
+
);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
} finally {
|
|
117
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
118
|
+
}
|
|
119
|
+
}
|