consumed-work.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { describe, expect, it } from 'vitest'
  2. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import type { TurnEndReason } from '@deepseek-ai/dsh-session'
  5. import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
  6. /** One pending message, as the inbox records it. */
  7. function message(text: string) {
  8. return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
  9. }
  10. /** Log an accepted message the way `Inbox.append()` does. */
  11. function accept(session: Session, text: string): void {
  12. session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message(text)] })
  13. }
  14. /** Log the step-boundary read of one pending message, as `Inbox.claim()` does. */
  15. function claim(session: Session): void {
  16. session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] })
  17. }
  18. /** Log a cancellation of one pending message, as `Inbox.clear()` does. */
  19. function cancelPending(session: Session): void {
  20. session.append('agent/inbox/spliced', {
  21. target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
  22. })
  23. }
  24. /** Run one whole turn that reached a model step. */
  25. function steppedTurn(session: Session, turn: number, reason: TurnEndReason): void {
  26. session.append('turn/start', { turn })
  27. claim(session)
  28. session.append('step/start', { turn, step: 1 })
  29. session.append('step/end', { turn, step: 1 })
  30. session.append('turn/end', { turn, reason })
  31. }
  32. describe('foldConsumedWork', () => {
  33. it('reports nothing for a log that consumed no work', () => {
  34. const session = Session.create(SessionId('empty'))
  35. accept(session, 'queued')
  36. expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false })
  37. })
  38. it('reports the latest turn that entered a model step', () => {
  39. const session = Session.create(SessionId('stepped'))
  40. steppedTurn(session, 1, { kind: 'completed' })
  41. steppedTurn(session, 2, { kind: 'max-tokens' })
  42. expect(foldConsumedWork(session.events).end?.data)
  43. .toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
  44. })
  45. it('reports a turn that claimed its input and then failed before any step', () => {
  46. const session = Session.create(SessionId('failed-claim'))
  47. steppedTurn(session, 1, { kind: 'completed' })
  48. // The step boundary runs the durability checkpoint and prompt assembly, so a
  49. // turn can take its input and then fail without entering a step.
  50. session.append('turn/start', { turn: 2 })
  51. claim(session)
  52. session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } })
  53. expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
  54. })
  55. it('reports a turn that claimed its input and was then stopped before any step', () => {
  56. const session = Session.create(SessionId('stopped-claim'))
  57. steppedTurn(session, 1, { kind: 'completed' })
  58. session.append('turn/start', { turn: 2 })
  59. claim(session)
  60. session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
  61. expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
  62. })
  63. it('ignores a turn stopped, failed, or rejected without taking any input', () => {
  64. const session = Session.create(SessionId('no-claim'))
  65. steppedTurn(session, 1, { kind: 'completed' })
  66. session.append('turn/start', { turn: 2 })
  67. session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'parent' } } })
  68. session.append('turn/start', { turn: 3 })
  69. session.append('turn/end', { turn: 3, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } })
  70. session.append('turn/start', { turn: 4 })
  71. session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } })
  72. // None of these turns describes work: they opened, found nothing of their own, and closed.
  73. expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
  74. })
  75. it('reports a turn whose claimed input a pre-step rejection discarded', () => {
  76. const session = Session.create(SessionId('rejected-claim'))
  77. steppedTurn(session, 1, { kind: 'completed' })
  78. session.append('turn/start', { turn: 2 })
  79. claim(session)
  80. session.append('turn/end', { turn: 2, reason: { kind: 'blocked' } })
  81. // Rejection does not retain the claimed messages, so the `blocked` end is
  82. // the only account of input that will never run.
  83. expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
  84. })
  85. it('ignores a claim its own turn emptied', () => {
  86. const session = Session.create(SessionId('emptied-claim'))
  87. steppedTurn(session, 1, { kind: 'completed' })
  88. session.append('turn/start', { turn: 2 })
  89. claim(session)
  90. session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  91. // An emptied claim ran nothing and dropped nothing: a listener rewrote the
  92. // batch away, which is not this log's account of the work.
  93. expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
  94. })
  95. it('credits a claim with no open turn to no turn at all', () => {
  96. const session = Session.create(SessionId('mid-turn-suffix'))
  97. steppedTurn(session, 1, { kind: 'completed' })
  98. // An owned suffix can begin inside a turn whose start it does not contain,
  99. // so a claim may appear with no turn to attribute it to.
  100. claim(session)
  101. session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
  102. expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
  103. })
  104. it('reports work cancelled out of the inbox after the last accounting turn', () => {
  105. const session = Session.create(SessionId('dropped'))
  106. steppedTurn(session, 1, { kind: 'completed' })
  107. accept(session, 'never runs')
  108. cancelPending(session)
  109. // No turn opened over it, so only the cancellation says the work was cut short.
  110. expect(foldConsumedWork(session.events)).toEqual({
  111. end: session.events.find(event => event.type === 'turn/end'),
  112. droppedUnrun: true,
  113. })
  114. })
  115. it('keeps a replacement pending rather than counting it as dropped', () => {
  116. const session = Session.create(SessionId('replaced'))
  117. steppedTurn(session, 1, { kind: 'completed' })
  118. session.append('agent/inbox/spliced', {
  119. target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled',
  120. })
  121. expect(foldConsumedWork(session.events).droppedUnrun).toBe(false)
  122. })
  123. it('lets a later accounting turn absorb an earlier drop', () => {
  124. const session = Session.create(SessionId('absorbed'))
  125. steppedTurn(session, 1, { kind: 'completed' })
  126. cancelPending(session)
  127. steppedTurn(session, 2, { kind: 'completed' })
  128. expect(foldConsumedWork(session.events)).toEqual({
  129. end: session.events.findLast(event => event.type === 'turn/end'),
  130. droppedUnrun: false,
  131. })
  132. })
  133. })