kimaki 0.25.0 → 0.26.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/dist/agent-model.e2e.test.js +135 -3
- package/dist/analytics.test.js +30 -1
- package/dist/cli-commands/send.js +13 -3
- package/dist/cli-commands/task.js +26 -7
- package/dist/cli-runner.js +66 -70
- package/dist/cli-runner.test.js +10 -1
- package/dist/commands/agent.js +87 -74
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +7 -1
- package/dist/ipc-tools-plugin.js +4 -3
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/message-formatting.js +84 -5
- package/dist/message-formatting.test.js +160 -2
- package/dist/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -0
- package/dist/schema.js +19 -0
- package/dist/session-handler/event-stream-state.js +147 -0
- package/dist/session-handler/event-stream-state.test.js +437 -1
- package/dist/session-handler/thread-session-runtime.js +90 -37
- package/dist/subagent-rate-limit-plugin.js +13 -2
- package/dist/system-message.js +18 -4
- package/dist/system-message.test.js +28 -4
- package/dist/task-runner.js +143 -12
- package/dist/task-schedule.js +12 -0
- package/dist/task-schedule.test.js +149 -2
- package/dist/xai-auth-plugin.js +237 -0
- package/dist/xai-auth-state.js +233 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +6 -6
- package/skills/profano/SKILL.md +5 -3
- package/skills/spiceflow/SKILL.md +24 -1
- package/src/agent-model.e2e.test.ts +172 -4
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/cli-commands/send.ts +22 -3
- package/src/cli-commands/task.ts +32 -19
- package/src/cli-runner.test.ts +16 -1
- package/src/cli-runner.ts +88 -89
- package/src/commands/agent.ts +120 -84
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -1
- package/src/ipc-tools-plugin.ts +4 -3
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/message-formatting.test.ts +197 -2
- package/src/message-formatting.ts +98 -5
- package/src/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -0
- package/src/schema.sql +15 -0
- package/src/schema.ts +20 -0
- package/src/session-handler/event-stream-state.test.ts +490 -0
- package/src/session-handler/event-stream-state.ts +210 -0
- package/src/session-handler/model-utils.ts +1 -0
- package/src/session-handler/thread-session-runtime.ts +105 -44
- package/src/subagent-rate-limit-plugin.ts +12 -2
- package/src/system-message.test.ts +29 -4
- package/src/system-message.ts +20 -4
- package/src/task-runner.ts +168 -11
- package/src/task-schedule.test.ts +172 -2
- package/src/task-schedule.ts +23 -2
- package/src/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +293 -0
|
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { describe, expect, test } from 'vitest';
|
|
6
6
|
import {} from './opencode-session-event-log.js';
|
|
7
|
-
import { derivePendingPermissionRequests, getAssistantMessageIdsForLatestUserTurn, getDerivedSubagentSessions, getEventBufferSessionId, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, isSessionBusy, } from './event-stream-state.js';
|
|
7
|
+
import { derivePendingPermissionRequests, getAssistantMessageIdsForLatestUserTurn, getDerivedSubagentSessions, getEventBufferSessionId, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, getLatestTurnTokenUsage, getIdleTokenUsageDelta, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, isSessionBusy, } from './event-stream-state.js';
|
|
8
8
|
const fixturesDir = path.join(import.meta.dirname, 'event-stream-fixtures');
|
|
9
9
|
function loadFixture(filename) {
|
|
10
10
|
const content = fs.readFileSync(path.join(fixturesDir, filename), 'utf8');
|
|
@@ -741,3 +741,439 @@ describe('real-session-footer-suppressed-on-pre-idle-interrupt', () => {
|
|
|
741
741
|
})).toBe(true);
|
|
742
742
|
});
|
|
743
743
|
});
|
|
744
|
+
describe('getLatestTurnTokenUsage', () => {
|
|
745
|
+
function userEvent({ sessionId, messageId, created, }) {
|
|
746
|
+
return eventEntry({
|
|
747
|
+
type: 'message.updated',
|
|
748
|
+
properties: {
|
|
749
|
+
sessionID: sessionId,
|
|
750
|
+
info: {
|
|
751
|
+
id: messageId,
|
|
752
|
+
sessionID: sessionId,
|
|
753
|
+
role: 'user',
|
|
754
|
+
time: { created },
|
|
755
|
+
agent: 'build',
|
|
756
|
+
model: {
|
|
757
|
+
providerID: 'openai',
|
|
758
|
+
modelID: 'gpt-5.3-codex',
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
function assistantEvent({ sessionId, messageId, parentID, created, tokens, cost = 0, modelID = 'gpt-5.3-codex', providerID = 'openai', }) {
|
|
765
|
+
return eventEntry({
|
|
766
|
+
type: 'message.updated',
|
|
767
|
+
properties: {
|
|
768
|
+
sessionID: sessionId,
|
|
769
|
+
info: {
|
|
770
|
+
id: messageId,
|
|
771
|
+
sessionID: sessionId,
|
|
772
|
+
role: 'assistant',
|
|
773
|
+
time: { created, completed: created + 1 },
|
|
774
|
+
parentID,
|
|
775
|
+
modelID,
|
|
776
|
+
providerID,
|
|
777
|
+
mode: 'build',
|
|
778
|
+
agent: 'build',
|
|
779
|
+
path: { cwd: '/test', root: '/test' },
|
|
780
|
+
cost,
|
|
781
|
+
tokens,
|
|
782
|
+
finish: 'stop',
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
test('sums latest snapshot per assistant message in the latest turn', () => {
|
|
788
|
+
const sessionId = 'ses_tokens';
|
|
789
|
+
const events = [
|
|
790
|
+
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
791
|
+
assistantEvent({
|
|
792
|
+
sessionId,
|
|
793
|
+
messageId: 'msg_asst_1',
|
|
794
|
+
parentID: 'msg_user_1',
|
|
795
|
+
created: 2,
|
|
796
|
+
tokens: {
|
|
797
|
+
input: 0,
|
|
798
|
+
output: 0,
|
|
799
|
+
reasoning: 0,
|
|
800
|
+
cache: { read: 0, write: 0 },
|
|
801
|
+
},
|
|
802
|
+
}),
|
|
803
|
+
assistantEvent({
|
|
804
|
+
sessionId,
|
|
805
|
+
messageId: 'msg_asst_1',
|
|
806
|
+
parentID: 'msg_user_1',
|
|
807
|
+
created: 2,
|
|
808
|
+
tokens: {
|
|
809
|
+
input: 100,
|
|
810
|
+
output: 20,
|
|
811
|
+
reasoning: 5,
|
|
812
|
+
cache: { read: 10, write: 2 },
|
|
813
|
+
},
|
|
814
|
+
cost: 1,
|
|
815
|
+
}),
|
|
816
|
+
assistantEvent({
|
|
817
|
+
sessionId,
|
|
818
|
+
messageId: 'msg_asst_2',
|
|
819
|
+
parentID: 'msg_user_1',
|
|
820
|
+
created: 3,
|
|
821
|
+
tokens: {
|
|
822
|
+
input: 50,
|
|
823
|
+
output: 8,
|
|
824
|
+
reasoning: 1,
|
|
825
|
+
cache: { read: 4, write: 0 },
|
|
826
|
+
},
|
|
827
|
+
cost: 2,
|
|
828
|
+
}),
|
|
829
|
+
];
|
|
830
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
|
|
831
|
+
input: 150,
|
|
832
|
+
output: 28,
|
|
833
|
+
reasoning: 6,
|
|
834
|
+
cacheRead: 14,
|
|
835
|
+
cacheWrite: 2,
|
|
836
|
+
total: 200,
|
|
837
|
+
cost: 3,
|
|
838
|
+
model: 'gpt-5.3-codex',
|
|
839
|
+
providerID: 'openai',
|
|
840
|
+
assistantMessageCount: 2,
|
|
841
|
+
userMessageId: 'msg_user_1',
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
test('ignores previous-turn assistant messages', () => {
|
|
845
|
+
const sessionId = 'ses_tokens_turns';
|
|
846
|
+
const events = [
|
|
847
|
+
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
848
|
+
assistantEvent({
|
|
849
|
+
sessionId,
|
|
850
|
+
messageId: 'msg_asst_1',
|
|
851
|
+
parentID: 'msg_user_1',
|
|
852
|
+
created: 2,
|
|
853
|
+
tokens: {
|
|
854
|
+
input: 1000,
|
|
855
|
+
output: 100,
|
|
856
|
+
reasoning: 0,
|
|
857
|
+
cache: { read: 0, write: 0 },
|
|
858
|
+
},
|
|
859
|
+
}),
|
|
860
|
+
userEvent({ sessionId, messageId: 'msg_user_2', created: 3 }),
|
|
861
|
+
assistantEvent({
|
|
862
|
+
sessionId,
|
|
863
|
+
messageId: 'msg_asst_2',
|
|
864
|
+
parentID: 'msg_user_2',
|
|
865
|
+
created: 4,
|
|
866
|
+
tokens: {
|
|
867
|
+
input: 7,
|
|
868
|
+
output: 3,
|
|
869
|
+
reasoning: 0,
|
|
870
|
+
cache: { read: 0, write: 0 },
|
|
871
|
+
},
|
|
872
|
+
modelID: 'gemini-2.5-flash',
|
|
873
|
+
providerID: 'google',
|
|
874
|
+
}),
|
|
875
|
+
];
|
|
876
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
|
|
877
|
+
input: 7,
|
|
878
|
+
output: 3,
|
|
879
|
+
reasoning: 0,
|
|
880
|
+
cacheRead: 0,
|
|
881
|
+
cacheWrite: 0,
|
|
882
|
+
total: 10,
|
|
883
|
+
cost: 0,
|
|
884
|
+
model: 'gemini-2.5-flash',
|
|
885
|
+
providerID: 'google',
|
|
886
|
+
assistantMessageCount: 1,
|
|
887
|
+
userMessageId: 'msg_user_2',
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
test('returns zeros when the latest turn has no assistant tokens', () => {
|
|
891
|
+
const sessionId = 'ses_empty';
|
|
892
|
+
const events = [
|
|
893
|
+
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
894
|
+
];
|
|
895
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
|
|
896
|
+
input: 0,
|
|
897
|
+
output: 0,
|
|
898
|
+
reasoning: 0,
|
|
899
|
+
cacheRead: 0,
|
|
900
|
+
cacheWrite: 0,
|
|
901
|
+
total: 0,
|
|
902
|
+
cost: 0,
|
|
903
|
+
model: undefined,
|
|
904
|
+
providerID: undefined,
|
|
905
|
+
assistantMessageCount: 0,
|
|
906
|
+
userMessageId: 'msg_user_1',
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
test('real-session-task-normal uses the completed assistant snapshot', () => {
|
|
910
|
+
const events = loadFixture('real-session-task-normal.jsonl');
|
|
911
|
+
const sessionId = getSessionId(events);
|
|
912
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
|
|
913
|
+
input: 39025,
|
|
914
|
+
output: 0,
|
|
915
|
+
reasoning: 0,
|
|
916
|
+
cacheRead: 0,
|
|
917
|
+
cacheWrite: 0,
|
|
918
|
+
total: 39025,
|
|
919
|
+
cost: 0,
|
|
920
|
+
model: 'gemini-2.5-flash',
|
|
921
|
+
providerID: 'cached-google-real-events',
|
|
922
|
+
assistantMessageCount: 1,
|
|
923
|
+
userMessageId: 'msg_cb9aae4c9001CxCOkoqgiXRsi1',
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
test('real-session-task-user-interruption sums both assistant steps', () => {
|
|
927
|
+
const events = loadFixture('real-session-task-user-interruption.jsonl');
|
|
928
|
+
const sessionId = getSessionId(events);
|
|
929
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
|
|
930
|
+
input: 82526,
|
|
931
|
+
output: 79,
|
|
932
|
+
reasoning: 115,
|
|
933
|
+
cacheRead: 0,
|
|
934
|
+
cacheWrite: 0,
|
|
935
|
+
total: 82720,
|
|
936
|
+
cost: 0,
|
|
937
|
+
model: 'gemini-2.5-flash',
|
|
938
|
+
providerID: 'cached-google-real-events',
|
|
939
|
+
assistantMessageCount: 2,
|
|
940
|
+
userMessageId: 'msg_cb9b0ba6c001i3YH7bGffdB6BF',
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
test('uses tokens.total when it differs from the component sum', () => {
|
|
944
|
+
const sessionId = 'ses_canonical_total';
|
|
945
|
+
const events = [
|
|
946
|
+
userEvent({ sessionId, messageId: 'msg_user_1', created: 1 }),
|
|
947
|
+
assistantEvent({
|
|
948
|
+
sessionId,
|
|
949
|
+
messageId: 'msg_asst_1',
|
|
950
|
+
parentID: 'msg_user_1',
|
|
951
|
+
created: 2,
|
|
952
|
+
tokens: {
|
|
953
|
+
total: 47319,
|
|
954
|
+
input: 1217,
|
|
955
|
+
output: 278,
|
|
956
|
+
reasoning: 54,
|
|
957
|
+
cache: { read: 45824, write: 0 },
|
|
958
|
+
},
|
|
959
|
+
}),
|
|
960
|
+
];
|
|
961
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toMatchObject({
|
|
962
|
+
input: 1217,
|
|
963
|
+
output: 278,
|
|
964
|
+
reasoning: 54,
|
|
965
|
+
cacheRead: 45824,
|
|
966
|
+
cacheWrite: 0,
|
|
967
|
+
total: 47319,
|
|
968
|
+
assistantMessageCount: 1,
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
test('real-session-task-three-parallel-sleeps uses billed totals', () => {
|
|
972
|
+
const events = loadFixture('real-session-task-three-parallel-sleeps.jsonl');
|
|
973
|
+
const sessionId = getSessionId(events);
|
|
974
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toMatchObject({
|
|
975
|
+
input: 47139,
|
|
976
|
+
output: 1093,
|
|
977
|
+
reasoning: 472,
|
|
978
|
+
cacheRead: 45824,
|
|
979
|
+
cacheWrite: 0,
|
|
980
|
+
total: 94056,
|
|
981
|
+
model: 'gpt-5.3-codex',
|
|
982
|
+
providerID: 'openai',
|
|
983
|
+
assistantMessageCount: 2,
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
test('returns empty when the session has no user message', () => {
|
|
987
|
+
const sessionId = 'ses_no_user';
|
|
988
|
+
const events = [
|
|
989
|
+
assistantEvent({
|
|
990
|
+
sessionId,
|
|
991
|
+
messageId: 'msg_asst_1',
|
|
992
|
+
parentID: 'msg_missing',
|
|
993
|
+
created: 2,
|
|
994
|
+
tokens: {
|
|
995
|
+
input: 100,
|
|
996
|
+
output: 10,
|
|
997
|
+
reasoning: 0,
|
|
998
|
+
cache: { read: 0, write: 0 },
|
|
999
|
+
},
|
|
1000
|
+
}),
|
|
1001
|
+
];
|
|
1002
|
+
expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
|
|
1003
|
+
input: 0,
|
|
1004
|
+
output: 0,
|
|
1005
|
+
reasoning: 0,
|
|
1006
|
+
cacheRead: 0,
|
|
1007
|
+
cacheWrite: 0,
|
|
1008
|
+
total: 0,
|
|
1009
|
+
cost: 0,
|
|
1010
|
+
model: undefined,
|
|
1011
|
+
providerID: undefined,
|
|
1012
|
+
assistantMessageCount: 0,
|
|
1013
|
+
userMessageId: undefined,
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
describe('getIdleTokenUsageDelta', () => {
|
|
1018
|
+
function idleEvent(sessionId) {
|
|
1019
|
+
return eventEntry({
|
|
1020
|
+
type: 'session.idle',
|
|
1021
|
+
properties: { sessionID: sessionId },
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
test('emits the first idle snapshot and skips a later idle with the same tokens', () => {
|
|
1025
|
+
const sessionId = 'ses_delta';
|
|
1026
|
+
const events = [
|
|
1027
|
+
eventEntry({
|
|
1028
|
+
type: 'message.updated',
|
|
1029
|
+
properties: {
|
|
1030
|
+
sessionID: sessionId,
|
|
1031
|
+
info: {
|
|
1032
|
+
id: 'msg_user_1',
|
|
1033
|
+
sessionID: sessionId,
|
|
1034
|
+
role: 'user',
|
|
1035
|
+
time: { created: 1 },
|
|
1036
|
+
agent: 'build',
|
|
1037
|
+
model: {
|
|
1038
|
+
providerID: 'openai',
|
|
1039
|
+
modelID: 'gpt-5.3-codex',
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
}),
|
|
1044
|
+
eventEntry({
|
|
1045
|
+
type: 'message.updated',
|
|
1046
|
+
properties: {
|
|
1047
|
+
sessionID: sessionId,
|
|
1048
|
+
info: {
|
|
1049
|
+
id: 'msg_asst_1',
|
|
1050
|
+
sessionID: sessionId,
|
|
1051
|
+
role: 'assistant',
|
|
1052
|
+
time: { created: 2, completed: 3 },
|
|
1053
|
+
parentID: 'msg_user_1',
|
|
1054
|
+
modelID: 'gpt-5.3-codex',
|
|
1055
|
+
providerID: 'openai',
|
|
1056
|
+
mode: 'build',
|
|
1057
|
+
agent: 'build',
|
|
1058
|
+
path: { cwd: '/test', root: '/test' },
|
|
1059
|
+
cost: 0,
|
|
1060
|
+
tokens: {
|
|
1061
|
+
input: 10,
|
|
1062
|
+
output: 5,
|
|
1063
|
+
reasoning: 0,
|
|
1064
|
+
cache: { read: 0, write: 0 },
|
|
1065
|
+
},
|
|
1066
|
+
finish: 'stop',
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
}),
|
|
1070
|
+
idleEvent(sessionId),
|
|
1071
|
+
idleEvent(sessionId),
|
|
1072
|
+
];
|
|
1073
|
+
const firstIdleIndex = events.length - 2;
|
|
1074
|
+
const secondIdleIndex = events.length - 1;
|
|
1075
|
+
expect(getIdleTokenUsageDelta({
|
|
1076
|
+
events,
|
|
1077
|
+
sessionId,
|
|
1078
|
+
idleEventIndex: firstIdleIndex,
|
|
1079
|
+
})).toMatchObject({
|
|
1080
|
+
total: 15,
|
|
1081
|
+
});
|
|
1082
|
+
expect(getIdleTokenUsageDelta({
|
|
1083
|
+
events,
|
|
1084
|
+
sessionId,
|
|
1085
|
+
idleEventIndex: secondIdleIndex,
|
|
1086
|
+
})).toBeUndefined();
|
|
1087
|
+
});
|
|
1088
|
+
test('emits the growth after an early idle that saw zero tokens', () => {
|
|
1089
|
+
const sessionId = 'ses_late_tokens';
|
|
1090
|
+
const events = [
|
|
1091
|
+
eventEntry({
|
|
1092
|
+
type: 'message.updated',
|
|
1093
|
+
properties: {
|
|
1094
|
+
sessionID: sessionId,
|
|
1095
|
+
info: {
|
|
1096
|
+
id: 'msg_user_1',
|
|
1097
|
+
sessionID: sessionId,
|
|
1098
|
+
role: 'user',
|
|
1099
|
+
time: { created: 1 },
|
|
1100
|
+
agent: 'build',
|
|
1101
|
+
model: {
|
|
1102
|
+
providerID: 'openai',
|
|
1103
|
+
modelID: 'gpt-5.3-codex',
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
}),
|
|
1108
|
+
eventEntry({
|
|
1109
|
+
type: 'message.updated',
|
|
1110
|
+
properties: {
|
|
1111
|
+
sessionID: sessionId,
|
|
1112
|
+
info: {
|
|
1113
|
+
id: 'msg_asst_1',
|
|
1114
|
+
sessionID: sessionId,
|
|
1115
|
+
role: 'assistant',
|
|
1116
|
+
time: { created: 2 },
|
|
1117
|
+
parentID: 'msg_user_1',
|
|
1118
|
+
modelID: 'gpt-5.3-codex',
|
|
1119
|
+
providerID: 'openai',
|
|
1120
|
+
mode: 'build',
|
|
1121
|
+
agent: 'build',
|
|
1122
|
+
path: { cwd: '/test', root: '/test' },
|
|
1123
|
+
cost: 0,
|
|
1124
|
+
tokens: {
|
|
1125
|
+
input: 0,
|
|
1126
|
+
output: 0,
|
|
1127
|
+
reasoning: 0,
|
|
1128
|
+
cache: { read: 0, write: 0 },
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
}),
|
|
1133
|
+
idleEvent(sessionId),
|
|
1134
|
+
eventEntry({
|
|
1135
|
+
type: 'message.updated',
|
|
1136
|
+
properties: {
|
|
1137
|
+
sessionID: sessionId,
|
|
1138
|
+
info: {
|
|
1139
|
+
id: 'msg_asst_1',
|
|
1140
|
+
sessionID: sessionId,
|
|
1141
|
+
role: 'assistant',
|
|
1142
|
+
time: { created: 2, completed: 4 },
|
|
1143
|
+
parentID: 'msg_user_1',
|
|
1144
|
+
modelID: 'gpt-5.3-codex',
|
|
1145
|
+
providerID: 'openai',
|
|
1146
|
+
mode: 'build',
|
|
1147
|
+
agent: 'build',
|
|
1148
|
+
path: { cwd: '/test', root: '/test' },
|
|
1149
|
+
cost: 0,
|
|
1150
|
+
tokens: {
|
|
1151
|
+
total: 20,
|
|
1152
|
+
input: 12,
|
|
1153
|
+
output: 8,
|
|
1154
|
+
reasoning: 3,
|
|
1155
|
+
cache: { read: 0, write: 0 },
|
|
1156
|
+
},
|
|
1157
|
+
finish: 'stop',
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
}),
|
|
1161
|
+
idleEvent(sessionId),
|
|
1162
|
+
];
|
|
1163
|
+
expect(getIdleTokenUsageDelta({
|
|
1164
|
+
events,
|
|
1165
|
+
sessionId,
|
|
1166
|
+
idleEventIndex: 2,
|
|
1167
|
+
})).toBeUndefined();
|
|
1168
|
+
expect(getIdleTokenUsageDelta({
|
|
1169
|
+
events,
|
|
1170
|
+
sessionId,
|
|
1171
|
+
idleEventIndex: events.length - 1,
|
|
1172
|
+
})).toMatchObject({
|
|
1173
|
+
input: 12,
|
|
1174
|
+
output: 8,
|
|
1175
|
+
reasoning: 3,
|
|
1176
|
+
total: 20,
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
@@ -16,8 +16,8 @@ import { isAbortError } from '../utils.js';
|
|
|
16
16
|
import { registerEventListener, unregisterEventListener, waitForGlobalEventListener, } from './global-event-listener.js';
|
|
17
17
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
18
18
|
import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
19
|
-
import { formatPart } from '../message-formatting.js';
|
|
20
|
-
import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
|
|
19
|
+
import { formatPart, formatTaskToolTitle } from '../message-formatting.js';
|
|
20
|
+
import { getChannelVerbosity, getPartMessageIds, getDb, setPartMessage, getThreadSession, setThreadSession, getThreadParentSessionId, setThreadParentSessionId, getThreadWorktreeOrWorkspace, setSessionAgent, clearSessionModel, getVariantCascade, setSessionStartSource, completeScheduledTaskRunsForSession, failScheduledTaskRunsForSession, startScheduledTaskRunSession, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
|
|
21
21
|
import * as orm from 'drizzle-orm';
|
|
22
22
|
import * as schema from '../schema.js';
|
|
23
23
|
import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
|
|
@@ -30,7 +30,7 @@ import { getDataDir } from '../config.js';
|
|
|
30
30
|
import { trackEvent, } from '../analytics.js';
|
|
31
31
|
import { resolveValidatedAgentPreference } from './agent-utils.js';
|
|
32
32
|
import { appendOpencodeSessionEventLog, getOpencodeEventSessionId, isOpencodeSessionEventLogEnabled, } from './opencode-session-event-log.js';
|
|
33
|
-
import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
|
|
33
|
+
import { doesLatestUserTurnHaveNaturalCompletion, didQuestionQueueHandoffSinceLatestQuestionAsked, getAssistantMessageIdsForLatestUserTurn, getCurrentTurnStartTime, isSessionBusy, getLatestRunInfo, getIdleTokenUsageDelta, getDerivedSubtaskIndex, getDerivedSubtaskAgentType, getLatestAssistantMessageIdForLatestUserTurn, hasAssistantMessageCompletedBefore, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, } from './event-stream-state.js';
|
|
34
34
|
// Track multiple pending permissions per thread (keyed by permission ID).
|
|
35
35
|
// OpenCode handles blocking/sequencing — we just need to track all pending
|
|
36
36
|
// permissions to avoid duplicates and properly clean up on reply/teardown.
|
|
@@ -949,9 +949,20 @@ export class ThreadSessionRuntime {
|
|
|
949
949
|
await this.handlePartUpdated(event.properties.part);
|
|
950
950
|
break;
|
|
951
951
|
case 'session.idle':
|
|
952
|
+
await completeScheduledTaskRunsForSession(event.properties.sessionID);
|
|
952
953
|
await this.handleSessionIdle(event.properties.sessionID);
|
|
953
954
|
break;
|
|
954
955
|
case 'session.error':
|
|
956
|
+
if (event.properties.sessionID) {
|
|
957
|
+
const sessionError = event.properties.error;
|
|
958
|
+
const errorMessage = sessionError && typeof sessionError === 'object'
|
|
959
|
+
? String(sessionError.data?.message || sessionError.name || 'Session failed')
|
|
960
|
+
: 'Session failed';
|
|
961
|
+
await failScheduledTaskRunsForSession({
|
|
962
|
+
sessionId: event.properties.sessionID,
|
|
963
|
+
error: errorMessage,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
955
966
|
await this.handleSessionError(event.properties);
|
|
956
967
|
break;
|
|
957
968
|
case 'permission.asked':
|
|
@@ -1433,6 +1444,35 @@ export class ThreadSessionRuntime {
|
|
|
1433
1444
|
this.ensureTypingNow();
|
|
1434
1445
|
return;
|
|
1435
1446
|
}
|
|
1447
|
+
if (part.type === 'tool' &&
|
|
1448
|
+
part.tool === 'task' &&
|
|
1449
|
+
!this.state?.sentPartIds.has(part.id)) {
|
|
1450
|
+
const taskDisplay = formatTaskToolTitle(part);
|
|
1451
|
+
if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
|
|
1452
|
+
await this.flushBufferedParts({
|
|
1453
|
+
messageID: part.messageID,
|
|
1454
|
+
force: true,
|
|
1455
|
+
skipPartId: part.id,
|
|
1456
|
+
});
|
|
1457
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1458
|
+
const newIds = new Set(t.sentPartIds);
|
|
1459
|
+
newIds.add(part.id);
|
|
1460
|
+
return { ...t, sentPartIds: newIds };
|
|
1461
|
+
});
|
|
1462
|
+
const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
|
|
1463
|
+
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
1464
|
+
if (sendResult instanceof Error) {
|
|
1465
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
1466
|
+
const newIds = new Set(t.sentPartIds);
|
|
1467
|
+
newIds.delete(part.id);
|
|
1468
|
+
return { ...t, sentPartIds: newIds };
|
|
1469
|
+
});
|
|
1470
|
+
discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1436
1476
|
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1437
1477
|
await this.flushBufferedParts({
|
|
1438
1478
|
messageID: part.messageID,
|
|
@@ -1440,40 +1480,6 @@ export class ThreadSessionRuntime {
|
|
|
1440
1480
|
skipPartId: part.id,
|
|
1441
1481
|
});
|
|
1442
1482
|
await this.sendPartMessage({ part });
|
|
1443
|
-
// Track task tool spawning subtask sessions
|
|
1444
|
-
if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
|
|
1445
|
-
const description = typeof part.state.input?.description === 'string'
|
|
1446
|
-
? part.state.input.description
|
|
1447
|
-
: '';
|
|
1448
|
-
const agent = typeof part.state.input?.subagent_type === 'string'
|
|
1449
|
-
? part.state.input.subagent_type
|
|
1450
|
-
: 'task';
|
|
1451
|
-
const childSessionId = typeof part.state.metadata?.sessionId === 'string'
|
|
1452
|
-
? part.state.metadata.sessionId
|
|
1453
|
-
: '';
|
|
1454
|
-
if (description && childSessionId) {
|
|
1455
|
-
if ((await this.getVerbosity()) !== 'text_only') {
|
|
1456
|
-
const taskDisplay = `┣ ${agent} **${description}**`;
|
|
1457
|
-
threadState.updateThread(this.threadId, (t) => {
|
|
1458
|
-
const newIds = new Set(t.sentPartIds);
|
|
1459
|
-
newIds.add(part.id);
|
|
1460
|
-
return { ...t, sentPartIds: newIds };
|
|
1461
|
-
});
|
|
1462
|
-
const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
|
|
1463
|
-
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }));
|
|
1464
|
-
if (sendResult instanceof Error) {
|
|
1465
|
-
threadState.updateThread(this.threadId, (t) => {
|
|
1466
|
-
const newIds = new Set(t.sentPartIds);
|
|
1467
|
-
newIds.delete(part.id);
|
|
1468
|
-
return { ...t, sentPartIds: newIds };
|
|
1469
|
-
});
|
|
1470
|
-
discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
1483
|
return;
|
|
1478
1484
|
}
|
|
1479
1485
|
// Action buttons tool handler
|
|
@@ -1637,7 +1643,47 @@ export class ThreadSessionRuntime {
|
|
|
1637
1643
|
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id });
|
|
1638
1644
|
this.requestTypingRepulse();
|
|
1639
1645
|
}
|
|
1646
|
+
trackIdleTokenUsage(idleSessionId) {
|
|
1647
|
+
let idleEventIndex;
|
|
1648
|
+
for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
|
|
1649
|
+
const event = this.eventBuffer[i]?.event;
|
|
1650
|
+
if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
|
|
1651
|
+
idleEventIndex = i;
|
|
1652
|
+
break;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (idleEventIndex === undefined) {
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const usage = getIdleTokenUsageDelta({
|
|
1659
|
+
events: this.eventBuffer,
|
|
1660
|
+
sessionId: idleSessionId,
|
|
1661
|
+
idleEventIndex,
|
|
1662
|
+
});
|
|
1663
|
+
if (!usage) {
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const properties = {
|
|
1667
|
+
tokens_input: usage.input,
|
|
1668
|
+
tokens_output: usage.output,
|
|
1669
|
+
tokens_reasoning: usage.reasoning,
|
|
1670
|
+
tokens_cache_read: usage.cacheRead,
|
|
1671
|
+
tokens_cache_write: usage.cacheWrite,
|
|
1672
|
+
tokens_total: usage.total,
|
|
1673
|
+
cost: usage.cost,
|
|
1674
|
+
assistant_message_count: usage.assistantMessageCount,
|
|
1675
|
+
is_subagent: Boolean(this.getSubtaskInfoForSession(idleSessionId)),
|
|
1676
|
+
};
|
|
1677
|
+
if (usage.model) {
|
|
1678
|
+
properties.model = usage.model;
|
|
1679
|
+
}
|
|
1680
|
+
if (usage.providerID) {
|
|
1681
|
+
properties.provider = usage.providerID;
|
|
1682
|
+
}
|
|
1683
|
+
trackEvent('tokens_used', properties);
|
|
1684
|
+
}
|
|
1640
1685
|
async handleSessionIdle(idleSessionId) {
|
|
1686
|
+
this.trackIdleTokenUsage(idleSessionId);
|
|
1641
1687
|
const sessionId = this.state?.sessionId;
|
|
1642
1688
|
// ── Subtask idle ──────────────────────────────────────────
|
|
1643
1689
|
const subtask = this.getSubtaskInfoForSession(idleSessionId);
|
|
@@ -2331,6 +2377,13 @@ export class ThreadSessionRuntime {
|
|
|
2331
2377
|
await cleanupOnError(`✗ OpenCode API error: ${errorMessage}`);
|
|
2332
2378
|
return;
|
|
2333
2379
|
}
|
|
2380
|
+
if (input.sessionStartSource?.scheduledTaskRunId) {
|
|
2381
|
+
await startScheduledTaskRunSession({
|
|
2382
|
+
runId: input.sessionStartSource.scheduledTaskRunId,
|
|
2383
|
+
sessionId: session.id,
|
|
2384
|
+
projectDirectory: this.sdkDirectory,
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2334
2387
|
logger.log(`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`);
|
|
2335
2388
|
if (!input.noReply) {
|
|
2336
2389
|
trackTurnStarted({
|
|
@@ -10,6 +10,9 @@ const RATE_LIMIT_TEXT_PATTERNS = [
|
|
|
10
10
|
'retry after',
|
|
11
11
|
'too many requests',
|
|
12
12
|
'quota exceeded',
|
|
13
|
+
'balance exhausted',
|
|
14
|
+
'spending-limit',
|
|
15
|
+
'run out of credits',
|
|
13
16
|
];
|
|
14
17
|
function isRateLimitText(text) {
|
|
15
18
|
if (!text) {
|
|
@@ -66,7 +69,7 @@ function extractRateLimitReason(event) {
|
|
|
66
69
|
}
|
|
67
70
|
if (event.type === 'message.part.updated' && event.properties.part.type === 'retry') {
|
|
68
71
|
const retryError = event.properties.part.error;
|
|
69
|
-
if (retryError.data.statusCode === 429) {
|
|
72
|
+
if (retryError.data.statusCode === 429 || retryError.data.statusCode === 402) {
|
|
70
73
|
return retryError.data.message;
|
|
71
74
|
}
|
|
72
75
|
if (isRateLimitText(retryError.data.responseBody)) {
|
|
@@ -90,9 +93,17 @@ function extractRateLimitReason(event) {
|
|
|
90
93
|
if (!apiError) {
|
|
91
94
|
return undefined;
|
|
92
95
|
}
|
|
93
|
-
if (apiError.statusCode === 429) {
|
|
96
|
+
if (apiError.statusCode === 429 || apiError.statusCode === 402) {
|
|
94
97
|
return apiError.message;
|
|
95
98
|
}
|
|
99
|
+
// xAI returns 403 with spending-limit or run out of credits text.
|
|
100
|
+
// Only treat 403 as a usage limit if the body matches specific patterns,
|
|
101
|
+
// not all 403s (which can be auth errors handled elsewhere).
|
|
102
|
+
if (apiError.statusCode === 403) {
|
|
103
|
+
const errorText = [apiError.message, apiError.responseBody].filter(Boolean).join(' ');
|
|
104
|
+
if (isRateLimitText(errorText))
|
|
105
|
+
return apiError.message;
|
|
106
|
+
}
|
|
96
107
|
if (isRateLimitText(apiError.responseBody)) {
|
|
97
108
|
return apiError.responseBody;
|
|
98
109
|
}
|