@qduc/term2 0.39.0 → 0.40.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 +7 -2
- package/dist/app.d.ts +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +32 -2
- package/dist/app.js.map +1 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +131 -45
- package/dist/cli.js.map +1 -1
- package/dist/components/layout/BottomArea.d.ts.map +1 -1
- package/dist/components/layout/BottomArea.js +5 -3
- package/dist/components/layout/BottomArea.js.map +1 -1
- package/dist/components/prompt/QueuePausedPrompt.d.ts +1 -0
- package/dist/components/prompt/QueuePausedPrompt.d.ts.map +1 -1
- package/dist/components/prompt/QueuePausedPrompt.js +6 -1
- package/dist/components/prompt/QueuePausedPrompt.js.map +1 -1
- package/dist/gateway/gateway.d.ts.map +1 -1
- package/dist/gateway/gateway.js +204 -34
- package/dist/gateway/gateway.js.map +1 -1
- package/dist/gateway/persistence/projection.d.ts +17 -0
- package/dist/gateway/persistence/projection.d.ts.map +1 -1
- package/dist/gateway/persistence/projection.js +108 -10
- package/dist/gateway/persistence/projection.js.map +1 -1
- package/dist/gateway/runtime-factory.d.ts.map +1 -1
- package/dist/gateway/runtime-factory.js +1 -0
- package/dist/gateway/runtime-factory.js.map +1 -1
- package/dist/gateway/workspace-boundary-probe.d.ts +5 -0
- package/dist/gateway/workspace-boundary-probe.d.ts.map +1 -1
- package/dist/gateway/workspace-boundary-probe.js +6 -1
- package/dist/gateway/workspace-boundary-probe.js.map +1 -1
- package/dist/hooks/use-app-commands.d.ts +2 -1
- package/dist/hooks/use-app-commands.d.ts.map +1 -1
- package/dist/hooks/use-app-commands.js +16 -1
- package/dist/hooks/use-app-commands.js.map +1 -1
- package/dist/lib/input-owner.d.ts +2 -0
- package/dist/lib/input-owner.d.ts.map +1 -1
- package/dist/lib/input-owner.js +1 -1
- package/dist/lib/input-owner.js.map +1 -1
- package/dist/prompts/discord-communication.md +2 -0
- package/dist/services/control-socket/control-session-port.d.ts.map +1 -1
- package/dist/services/control-socket/control-session-port.js +5 -3
- package/dist/services/control-socket/control-session-port.js.map +1 -1
- package/dist/services/control-socket/control-socket.d.ts +5 -1
- package/dist/services/control-socket/control-socket.d.ts.map +1 -1
- package/dist/services/control-socket/control-socket.js +61 -8
- package/dist/services/control-socket/control-socket.js.map +1 -1
- package/dist/services/conversation/conversation-adapter.d.ts +2 -0
- package/dist/services/conversation/conversation-adapter.d.ts.map +1 -1
- package/dist/services/conversation/conversation-adapter.js +57 -3
- package/dist/services/conversation/conversation-adapter.js.map +1 -1
- package/dist/services/conversation/conversation-service.d.ts +2 -0
- package/dist/services/conversation/conversation-service.d.ts.map +1 -1
- package/dist/services/conversation/conversation-service.js +10 -0
- package/dist/services/conversation/conversation-service.js.map +1 -1
- package/dist/services/discord/discord-bridge.d.ts +4 -0
- package/dist/services/discord/discord-bridge.d.ts.map +1 -1
- package/dist/services/discord/discord-bridge.js +43 -10
- package/dist/services/discord/discord-bridge.js.map +1 -1
- package/dist/services/discord/discord-command.d.ts.map +1 -1
- package/dist/services/discord/discord-command.js +52 -5
- package/dist/services/discord/discord-command.js.map +1 -1
- package/dist/services/discord/discord-gateway.d.ts +20 -0
- package/dist/services/discord/discord-gateway.d.ts.map +1 -0
- package/dist/services/discord/discord-gateway.js +257 -0
- package/dist/services/discord/discord-gateway.js.map +1 -0
- package/dist/services/profiles/registry.d.ts.map +1 -1
- package/dist/services/profiles/registry.js +11 -1
- package/dist/services/profiles/registry.js.map +1 -1
- package/dist/services/queue/queue-controller.d.ts +3 -0
- package/dist/services/queue/queue-controller.d.ts.map +1 -1
- package/dist/services/queue/queue-controller.js +34 -1
- package/dist/services/queue/queue-controller.js.map +1 -1
- package/dist/skins/testing/scenes.js +1 -1
- package/dist/skins/testing/scenes.js.map +1 -1
- package/dist/test-helpers/cli-compile-cache-setup.d.ts +7 -0
- package/dist/test-helpers/cli-compile-cache-setup.d.ts.map +1 -0
- package/dist/test-helpers/cli-compile-cache-setup.js +7 -0
- package/dist/test-helpers/cli-compile-cache-setup.js.map +1 -0
- package/dist/test-helpers/cli-compile-cache.d.ts +4 -0
- package/dist/test-helpers/cli-compile-cache.d.ts.map +1 -0
- package/dist/test-helpers/cli-compile-cache.js +33 -0
- package/dist/test-helpers/cli-compile-cache.js.map +1 -0
- package/dist/test-helpers/integration-cli-global-setup.d.ts +3 -0
- package/dist/test-helpers/integration-cli-global-setup.d.ts.map +1 -0
- package/dist/test-helpers/integration-cli-global-setup.js +40 -0
- package/dist/test-helpers/integration-cli-global-setup.js.map +1 -0
- package/dist/test-helpers/integration-cli.d.ts +7 -0
- package/dist/test-helpers/integration-cli.d.ts.map +1 -0
- package/dist/test-helpers/integration-cli.js +8 -0
- package/dist/test-helpers/integration-cli.js.map +1 -0
- package/dist/test-helpers/terminal-e2e.d.ts.map +1 -1
- package/dist/test-helpers/terminal-e2e.js +3 -0
- package/dist/test-helpers/terminal-e2e.js.map +1 -1
- package/dist/tools/system/shell.d.ts.map +1 -1
- package/dist/tools/system/shell.js +10 -0
- package/dist/tools/system/shell.js.map +1 -1
- package/dist/utils/process-restart.d.ts +33 -0
- package/dist/utils/process-restart.d.ts.map +1 -0
- package/dist/utils/process-restart.js +52 -0
- package/dist/utils/process-restart.js.map +1 -0
- package/package.json +4 -3
package/dist/gateway/gateway.js
CHANGED
|
@@ -11,7 +11,7 @@ import { projectConversationMessage } from '../services/conversation/conversatio
|
|
|
11
11
|
import { normalizeRunItem } from '../services/conversation/run-item-normalizer.js';
|
|
12
12
|
import { FROZEN_AGENT_EVENT_TYPES } from '@qduc/agent-wire';
|
|
13
13
|
import { GatewayPersistenceError, TERMINAL_AGENT_EVENT_TYPES, } from './persistence/contracts.js';
|
|
14
|
-
import { createSessionProjectionSource, hydrateConversationService, } from './persistence/projection.js';
|
|
14
|
+
import { createSessionProjectionSource, wireCallId, hydrateConversationService, } from './persistence/projection.js';
|
|
15
15
|
import { decideInteraction, InteractionProtocolError, projectPendingInteraction, validatePendingInteractionDto, } from './interaction-protocol.js';
|
|
16
16
|
import { GatewayAdmissionPersistence, normalizedBodyHash } from './persistence/admission-persistence.js';
|
|
17
17
|
import { GATEWAY_EVENT_HEARTBEAT_INTERVAL_MS, GATEWAY_EVENT_STREAM_CONTENT_TYPE, GATEWAY_EVENT_STREAM_HEARTBEAT, } from './server.js';
|
|
@@ -32,6 +32,9 @@ import { isOAuthAccountProvider, listOAuthAccounts, removeOAuthAccount, setActiv
|
|
|
32
32
|
import { loginToCodex } from '../providers/codex-auth.js';
|
|
33
33
|
import { loginToGrok } from '../providers/grok-auth.js';
|
|
34
34
|
const MAX_BUFFERED_SSE_EVENTS = 256;
|
|
35
|
+
// How long the replay phase waits for a client that has stopped reading before
|
|
36
|
+
// it is treated as a slow subscriber and closed.
|
|
37
|
+
const SSE_REPLAY_DRAIN_TIMEOUT_MS = 10_000;
|
|
35
38
|
const NONCRITICAL_RUNTIME_EVENT_TYPES = new Set(['text_delta', 'reasoning_delta']);
|
|
36
39
|
let malformedCommandMessageDrops = 0;
|
|
37
40
|
const isCriticalRuntimeEvent = (event) => !NONCRITICAL_RUNTIME_EVENT_TYPES.has(event.type);
|
|
@@ -847,6 +850,10 @@ export class Term2Gateway {
|
|
|
847
850
|
ownerUserId,
|
|
848
851
|
sessionId,
|
|
849
852
|
journal: persisted.persistence.journal,
|
|
853
|
+
activeTurnId: (() => {
|
|
854
|
+
const live = this.#sessions.get(sessionId);
|
|
855
|
+
return live instanceof ServerSession ? live.activeTurnId : null;
|
|
856
|
+
})(),
|
|
850
857
|
liveInteraction: () => {
|
|
851
858
|
const session = this.#sessions.get(sessionId);
|
|
852
859
|
if (!(session instanceof ServerSession))
|
|
@@ -1555,12 +1562,19 @@ export class Term2Gateway {
|
|
|
1555
1562
|
});
|
|
1556
1563
|
this.#interactionBindings.delete(claims.sessionId);
|
|
1557
1564
|
this.#countInteraction('interaction_resolved');
|
|
1565
|
+
// A multi-question ask_user collects one answer per request; the resolved
|
|
1566
|
+
// result carries all of them (in question order), while this request only
|
|
1567
|
+
// carries the last. A single question keeps the answer as sent so its
|
|
1568
|
+
// tool result format does not change.
|
|
1569
|
+
const toolApprovalAnswer = (binding.dto.askUser?.questions.length ?? 0) > 1 && result.approvalAnswer !== undefined
|
|
1570
|
+
? result.approvalAnswer
|
|
1571
|
+
: decision.approvalAnswer;
|
|
1558
1572
|
try {
|
|
1559
1573
|
if (session.abortGeneration !== continuationAbortGeneration || session.activeTurnId !== binding.turnId) {
|
|
1560
1574
|
throw new ServerSessionError('interrupted');
|
|
1561
1575
|
}
|
|
1562
1576
|
await session.service.handleApprovalDecision(decision.answer, decision.rejectionReason, {
|
|
1563
|
-
approvalAnswer:
|
|
1577
|
+
approvalAnswer: toolApprovalAnswer,
|
|
1564
1578
|
// Cancelling an ask_user question ends the turn, exactly as Escape
|
|
1565
1579
|
// does in the TUI: the question and its unanswered result stay in
|
|
1566
1580
|
// history, and the user speaks next. Without this the web client's
|
|
@@ -1598,7 +1612,11 @@ export class Term2Gateway {
|
|
|
1598
1612
|
if (after.error)
|
|
1599
1613
|
return publicError(400, after.error, 'event cursor is invalid');
|
|
1600
1614
|
const source = await this.#projectionFor(claims.sub, claims.sessionId);
|
|
1615
|
+
// `activeResponse` is the socket to destroy; `liveWrites` flips on only once
|
|
1616
|
+
// the replay and every event queued behind it have been written, so live
|
|
1617
|
+
// events never overtake the replay.
|
|
1601
1618
|
let activeResponse = null;
|
|
1619
|
+
let liveWrites = false;
|
|
1602
1620
|
let closed = false;
|
|
1603
1621
|
let buffered = [];
|
|
1604
1622
|
let unsubscribe = () => undefined;
|
|
@@ -1620,7 +1638,7 @@ export class Term2Gateway {
|
|
|
1620
1638
|
const live = source.subscribeFrom(after.value, (event) => {
|
|
1621
1639
|
if (closed)
|
|
1622
1640
|
return;
|
|
1623
|
-
if (!activeResponse) {
|
|
1641
|
+
if (!liveWrites || !activeResponse) {
|
|
1624
1642
|
if (buffered.length >= MAX_BUFFERED_SSE_EVENTS)
|
|
1625
1643
|
return closeSlowSubscriber();
|
|
1626
1644
|
buffered.push(event);
|
|
@@ -1654,18 +1672,59 @@ export class Term2Gateway {
|
|
|
1654
1672
|
stream: {
|
|
1655
1673
|
start: async (response, request) => {
|
|
1656
1674
|
activeResponse = response;
|
|
1675
|
+
// The replay can be much larger than the socket's write buffer (a
|
|
1676
|
+
// reload mid-way through a long turn replays the whole turn), so wait
|
|
1677
|
+
// for drain between writes. A client that stops reading for
|
|
1678
|
+
// SSE_REPLAY_DRAIN_TIMEOUT_MS is still closed as a slow subscriber.
|
|
1679
|
+
// Live events that arrive meanwhile queue in `buffered` (bounded by
|
|
1680
|
+
// MAX_BUFFERED_SSE_EVENTS) and are written after the replay, in order.
|
|
1681
|
+
const writeAwaitingDrain = async (event) => {
|
|
1682
|
+
if (!isPublicEventEnvelope(event))
|
|
1683
|
+
return closeSlowSubscriber();
|
|
1684
|
+
if (response.write(`id: ${event.id}\ndata: ${JSON.stringify(event)}\n\n`))
|
|
1685
|
+
return;
|
|
1686
|
+
await new Promise((resolve) => {
|
|
1687
|
+
const finish = () => {
|
|
1688
|
+
clearTimeout(timer);
|
|
1689
|
+
response.off('drain', finish);
|
|
1690
|
+
response.off('close', finish);
|
|
1691
|
+
resolve();
|
|
1692
|
+
};
|
|
1693
|
+
const timer = setTimeout(() => {
|
|
1694
|
+
closeSlowSubscriber();
|
|
1695
|
+
finish();
|
|
1696
|
+
}, SSE_REPLAY_DRAIN_TIMEOUT_MS);
|
|
1697
|
+
response.once('drain', finish);
|
|
1698
|
+
response.once('close', finish);
|
|
1699
|
+
});
|
|
1700
|
+
};
|
|
1701
|
+
request.once('close', () => {
|
|
1702
|
+
closed = true;
|
|
1703
|
+
});
|
|
1704
|
+
response.once('close', () => {
|
|
1705
|
+
closed = true;
|
|
1706
|
+
});
|
|
1657
1707
|
for (const event of live.replay) {
|
|
1658
1708
|
if (closed)
|
|
1659
1709
|
break;
|
|
1660
|
-
|
|
1710
|
+
await writeAwaitingDrain(event);
|
|
1661
1711
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1712
|
+
while (!closed && buffered.length > 0) {
|
|
1713
|
+
const queued = buffered;
|
|
1714
|
+
buffered = [];
|
|
1715
|
+
for (const event of queued) {
|
|
1716
|
+
if (closed)
|
|
1717
|
+
break;
|
|
1718
|
+
await writeAwaitingDrain(event);
|
|
1719
|
+
}
|
|
1668
1720
|
}
|
|
1721
|
+
if (closed) {
|
|
1722
|
+
unsubscribe();
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
// No await between the last empty check and this flag, so nothing
|
|
1726
|
+
// can be queued after it and left unwritten.
|
|
1727
|
+
liveWrites = true;
|
|
1669
1728
|
const heartbeat = setInterval(() => {
|
|
1670
1729
|
if (!response.writableEnded) {
|
|
1671
1730
|
const accepted = response.write(`${GATEWAY_EVENT_STREAM_HEARTBEAT} ${new Date().toISOString()}\n\n`);
|
|
@@ -2777,8 +2836,8 @@ function commandMessagePayload(input) {
|
|
|
2777
2836
|
return null;
|
|
2778
2837
|
}
|
|
2779
2838
|
return {
|
|
2780
|
-
callId:
|
|
2781
|
-
toolName:
|
|
2839
|
+
callId: wireCallId(callId),
|
|
2840
|
+
toolName: projectionText(toolName, 256),
|
|
2782
2841
|
status,
|
|
2783
2842
|
...(typeof message.output === 'string' ? { output: boundedText(message.output, 16_384) } : {}),
|
|
2784
2843
|
...(typeof message.failureReason === 'string' ? { error: boundedText(message.failureReason, 10_000) } : {}),
|
|
@@ -2836,7 +2895,74 @@ function sanitizePublicInteraction(value) {
|
|
|
2836
2895
|
return validatePendingInteractionDto(value);
|
|
2837
2896
|
}
|
|
2838
2897
|
const PROJECTION_COMMAND_STATUSES = new Set(['completed', 'failed', 'aborted', 'unknown']);
|
|
2839
|
-
|
|
2898
|
+
const PROJECTION_COMMAND_OUTPUT_MAX = 16_384;
|
|
2899
|
+
const PROJECTION_COMMAND_ERROR_MAX = 10_000;
|
|
2900
|
+
// Same credential shapes the event journal refuses to persist (event-journal.ts
|
|
2901
|
+
// assertSafePayload). A ledger-only body skips that check, so apply it here.
|
|
2902
|
+
const PROJECTION_CREDENTIAL_PATTERN = /^Bearer\s+|^sk-[A-Za-z0-9]/u;
|
|
2903
|
+
const PROJECTION_WITHHELD_MARKER = '[output withheld: looks like a credential]';
|
|
2904
|
+
// Total characters of command output + error one projection may carry. Bodies
|
|
2905
|
+
// beyond it are cut, newest turns first, so the response stays far below the
|
|
2906
|
+
// BFF's 32 MiB cap (worst case a few times this once JSON-escaped).
|
|
2907
|
+
const PROJECTION_COMMAND_BODY_BUDGET = 2 * 1024 * 1024;
|
|
2908
|
+
const PROJECTION_TRUNCATED_MARKER = '[truncated: session transcript size limit]';
|
|
2909
|
+
// The web client's transcript validator rejects NUL and DEL anywhere in a string,
|
|
2910
|
+
// so projected text carrying them would make the whole session unreadable.
|
|
2911
|
+
function projectionText(value, max) {
|
|
2912
|
+
return boundedText(value, max).replace(/[\u0000\u007f]/gu, '\uFFFD');
|
|
2913
|
+
}
|
|
2914
|
+
function projectionCommandText(value, max) {
|
|
2915
|
+
const bounded = projectionText(value, max);
|
|
2916
|
+
if (bounded.length === 0)
|
|
2917
|
+
return undefined;
|
|
2918
|
+
return PROJECTION_CREDENTIAL_PATTERN.test(bounded) ? PROJECTION_WITHHELD_MARKER : bounded;
|
|
2919
|
+
}
|
|
2920
|
+
function projectionCommandDetails(output, error) {
|
|
2921
|
+
const projectedOutput = projectionCommandText(output, PROJECTION_COMMAND_OUTPUT_MAX);
|
|
2922
|
+
const projectedError = projectionCommandText(error, PROJECTION_COMMAND_ERROR_MAX);
|
|
2923
|
+
return {
|
|
2924
|
+
// A rejected approval records the same sentence as output and as failure
|
|
2925
|
+
// reason; show it once.
|
|
2926
|
+
...(projectedOutput && projectedOutput !== projectedError ? { output: projectedOutput } : {}),
|
|
2927
|
+
...(projectedError ? { error: projectedError } : {}),
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
function fitToBudget(text, max, budget) {
|
|
2931
|
+
if (text.length <= budget.remaining) {
|
|
2932
|
+
budget.remaining -= text.length;
|
|
2933
|
+
return text;
|
|
2934
|
+
}
|
|
2935
|
+
const keep = Math.max(0, Math.min(budget.remaining, max - PROJECTION_TRUNCATED_MARKER.length - 1));
|
|
2936
|
+
budget.remaining = 0;
|
|
2937
|
+
return keep > 0 ? `${text.slice(0, keep)}\n${PROJECTION_TRUNCATED_MARKER}` : PROJECTION_TRUNCATED_MARKER;
|
|
2938
|
+
}
|
|
2939
|
+
// Walk the kept messages newest first and spend the budget on their command bodies.
|
|
2940
|
+
// A body that no longer fits is cut and ends with a visible marker.
|
|
2941
|
+
function applyCommandBodyBudget(messages) {
|
|
2942
|
+
const budget = { remaining: PROJECTION_COMMAND_BODY_BUDGET };
|
|
2943
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2944
|
+
const commands = messages[i].commands;
|
|
2945
|
+
if (!commands)
|
|
2946
|
+
continue;
|
|
2947
|
+
const fitted = [...commands];
|
|
2948
|
+
for (let j = fitted.length - 1; j >= 0; j--) {
|
|
2949
|
+
const { output, error } = fitted[j];
|
|
2950
|
+
if (output === undefined && error === undefined)
|
|
2951
|
+
continue;
|
|
2952
|
+
// Charge the short error first so a large output cannot use up its budget.
|
|
2953
|
+
const fittedError = error !== undefined ? fitToBudget(error, PROJECTION_COMMAND_ERROR_MAX, budget) : undefined;
|
|
2954
|
+
const fittedOutput = output !== undefined ? fitToBudget(output, PROJECTION_COMMAND_OUTPUT_MAX, budget) : undefined;
|
|
2955
|
+
fitted[j] = {
|
|
2956
|
+
...fitted[j],
|
|
2957
|
+
...(fittedOutput !== undefined ? { output: fittedOutput } : {}),
|
|
2958
|
+
...(fittedError !== undefined ? { error: fittedError } : {}),
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
messages[i] = { ...messages[i], commands: fitted };
|
|
2962
|
+
}
|
|
2963
|
+
return messages;
|
|
2964
|
+
}
|
|
2965
|
+
function projectTranscriptMessages(transcript, journalCommands, abortedTurns) {
|
|
2840
2966
|
if (!transcript)
|
|
2841
2967
|
return [];
|
|
2842
2968
|
const commandsByTurn = new Map();
|
|
@@ -2850,14 +2976,22 @@ function projectTranscriptMessages(transcript, journalCommands) {
|
|
|
2850
2976
|
if (!PROJECTION_COMMAND_STATUSES.has(command.status))
|
|
2851
2977
|
continue;
|
|
2852
2978
|
addCommand(command.turnId, {
|
|
2853
|
-
callId:
|
|
2979
|
+
callId: wireCallId(command.callId),
|
|
2854
2980
|
toolName: boundedText(command.toolName, 256),
|
|
2855
2981
|
status: boundedText(command.status, 32),
|
|
2982
|
+
...projectionCommandDetails(command.output, command.failureReason),
|
|
2856
2983
|
});
|
|
2857
2984
|
}
|
|
2858
2985
|
for (const [turnId, commands] of journalCommands) {
|
|
2859
|
-
for (const
|
|
2860
|
-
const existing = commandsByTurn.get(turnId)?.find((candidate) => candidate.callId ===
|
|
2986
|
+
for (const journalCommand of commands) {
|
|
2987
|
+
const existing = commandsByTurn.get(turnId)?.find((candidate) => candidate.callId === journalCommand.callId);
|
|
2988
|
+
// The journal owns status; the result body comes from the journal when it
|
|
2989
|
+
// recorded one, otherwise from the transcript ledger.
|
|
2990
|
+
const { output, error, ...journalBase } = journalCommand;
|
|
2991
|
+
const command = {
|
|
2992
|
+
...journalBase,
|
|
2993
|
+
...projectionCommandDetails(output ?? existing?.output, error ?? existing?.error),
|
|
2994
|
+
};
|
|
2861
2995
|
if (existing) {
|
|
2862
2996
|
const current = commandsByTurn.get(turnId);
|
|
2863
2997
|
commandsByTurn.set(turnId, current.map((candidate) => (candidate.callId === command.callId ? command : candidate)));
|
|
@@ -2869,12 +3003,41 @@ function projectTranscriptMessages(transcript, journalCommands) {
|
|
|
2869
3003
|
}
|
|
2870
3004
|
const projected = [];
|
|
2871
3005
|
let currentTurnId = null;
|
|
3006
|
+
let currentTurnHasReply = false;
|
|
2872
3007
|
const attachedTurns = new Set();
|
|
3008
|
+
const botMessage = (id, text) => {
|
|
3009
|
+
const projectedMessage = {
|
|
3010
|
+
id: boundedText(id, 256),
|
|
3011
|
+
role: 'bot',
|
|
3012
|
+
text: projectionText(text, 16_384),
|
|
3013
|
+
};
|
|
3014
|
+
const commands = currentTurnId ? commandsByTurn.get(currentTurnId) : undefined;
|
|
3015
|
+
if (commands && commands.length > 0 && !attachedTurns.has(currentTurnId)) {
|
|
3016
|
+
// Ledger, journal and transcript commands all end up here, so this is the one place their names are cleaned.
|
|
3017
|
+
projectedMessage.commands = commands.map((command) => ({
|
|
3018
|
+
...command,
|
|
3019
|
+
toolName: projectionText(command.toolName, 256),
|
|
3020
|
+
}));
|
|
3021
|
+
attachedTurns.add(currentTurnId);
|
|
3022
|
+
}
|
|
3023
|
+
return projectedMessage;
|
|
3024
|
+
};
|
|
3025
|
+
// A stopped turn settles only the journal, so the transcript holds its user
|
|
3026
|
+
// message and at most a synthetic "interrupted" note. Project the journal's
|
|
3027
|
+
// partial answer in that note's place, marked as stopped.
|
|
3028
|
+
const flushAbortedTurn = () => {
|
|
3029
|
+
const partial = currentTurnId && !currentTurnHasReply ? abortedTurns.get(currentTurnId) : undefined;
|
|
3030
|
+
if (partial === undefined)
|
|
3031
|
+
return;
|
|
3032
|
+
projected.push({ ...botMessage(`aborted-${currentTurnId}`, partial), status: 'aborted' });
|
|
3033
|
+
};
|
|
2873
3034
|
for (const message of transcript.messages) {
|
|
2874
3035
|
const record = message;
|
|
2875
3036
|
const sender = typeof record.sender === 'string' ? record.sender : record.role;
|
|
2876
3037
|
if (sender === 'user') {
|
|
3038
|
+
flushAbortedTurn();
|
|
2877
3039
|
currentTurnId = typeof record.id === 'string' ? record.id : null;
|
|
3040
|
+
currentTurnHasReply = false;
|
|
2878
3041
|
}
|
|
2879
3042
|
if (sender === 'command' || sender === 'reasoning') {
|
|
2880
3043
|
if (sender === 'command' &&
|
|
@@ -2882,38 +3045,45 @@ function projectTranscriptMessages(transcript, journalCommands) {
|
|
|
2882
3045
|
typeof record.callId === 'string' &&
|
|
2883
3046
|
typeof record.toolName === 'string' &&
|
|
2884
3047
|
typeof record.status === 'string' &&
|
|
2885
|
-
!commandsByTurn.get(currentTurnId)?.some((command) => command.callId === record.callId)) {
|
|
3048
|
+
!commandsByTurn.get(currentTurnId)?.some((command) => command.callId === wireCallId(String(record.callId)))) {
|
|
2886
3049
|
addCommand(currentTurnId, {
|
|
2887
|
-
callId:
|
|
3050
|
+
callId: wireCallId(record.callId),
|
|
2888
3051
|
toolName: boundedText(record.toolName, 256),
|
|
2889
3052
|
status: boundedText(record.status, 32),
|
|
3053
|
+
...projectionCommandDetails(record.output, record.failureReason),
|
|
2890
3054
|
});
|
|
2891
3055
|
}
|
|
2892
3056
|
continue;
|
|
2893
3057
|
}
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
3058
|
+
if (sender === 'bot')
|
|
3059
|
+
currentTurnHasReply = true;
|
|
3060
|
+
if (sender === 'system' &&
|
|
3061
|
+
currentTurnId &&
|
|
3062
|
+
!currentTurnHasReply &&
|
|
3063
|
+
abortedTurns.has(currentTurnId) &&
|
|
3064
|
+
typeof record.id === 'string' &&
|
|
3065
|
+
record.id.startsWith('system-interrupted-'))
|
|
3066
|
+
continue;
|
|
3067
|
+
if (sender === 'user') {
|
|
3068
|
+
projected.push({ id: boundedText(record.id, 256), role: 'user', text: projectionText(record.text, 16_384) });
|
|
3069
|
+
}
|
|
3070
|
+
else {
|
|
3071
|
+
projected.push(botMessage(record.id, record.text));
|
|
2904
3072
|
}
|
|
2905
|
-
projected.push(projectedMessage);
|
|
2906
3073
|
}
|
|
2907
|
-
|
|
3074
|
+
flushAbortedTurn();
|
|
3075
|
+
return applyCommandBodyBudget(projected.slice(-200));
|
|
2908
3076
|
}
|
|
2909
3077
|
/**
|
|
2910
3078
|
* Session transcript wire shape: messages have `id`, `role` (`user` or `bot`),
|
|
2911
3079
|
* and bounded `text`. A completed turn's first bot message may carry a bounded
|
|
2912
|
-
* `commands` array of `{callId, toolName, status}` entries for
|
|
3080
|
+
* `commands` array of `{callId, toolName, status, output?, error?}` entries for
|
|
3081
|
+
* its tool cards. `output` and `error` are bounded per command and by one total
|
|
3082
|
+
* budget per projection (newest first); a cut body ends with a visible marker.
|
|
2913
3083
|
*/
|
|
2914
3084
|
function toSessionProjection(source) {
|
|
2915
3085
|
const transcript = source.transcript;
|
|
2916
|
-
const messages = projectTranscriptMessages(transcript, source.journalCommands);
|
|
3086
|
+
const messages = projectTranscriptMessages(transcript, source.journalCommands, source.abortedTurns);
|
|
2917
3087
|
const interaction = source.interaction
|
|
2918
3088
|
? {
|
|
2919
3089
|
...source.interaction,
|
|
@@ -2980,7 +3150,7 @@ export function mapConversationEvent(event, turnId, sessionId, interactionBindin
|
|
|
2980
3150
|
return {
|
|
2981
3151
|
...base,
|
|
2982
3152
|
type: 'tool_started',
|
|
2983
|
-
payload: { turnId, callId: event.toolCallId, toolName:
|
|
3153
|
+
payload: { turnId, callId: wireCallId(event.toolCallId), toolName: projectionText(event.toolName, 256) },
|
|
2984
3154
|
};
|
|
2985
3155
|
case 'command_message':
|
|
2986
3156
|
return commandMessageCandidate(base, event.message, 'command_message');
|
|
@@ -3073,7 +3243,7 @@ export function mapConversationEvent(event, turnId, sessionId, interactionBindin
|
|
|
3073
3243
|
turnId,
|
|
3074
3244
|
agentId: boundedText(event.agentId, 256),
|
|
3075
3245
|
role: boundedText(event.role, 256),
|
|
3076
|
-
toolCallId:
|
|
3246
|
+
toolCallId: wireCallId(event.toolCallId),
|
|
3077
3247
|
toolName: boundedText(event.toolName, 256),
|
|
3078
3248
|
...(event.commandMessages
|
|
3079
3249
|
? {
|