Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions strands-ts/src/models/__tests__/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,29 +323,79 @@ describe('Model', () => {
)
})

it('preserves SyntaxError instead of overwriting with MaxTokensError when tool input JSON is malformed', async () => {
it('throws MaxTokenError when contentBlockStop arrives with truncated tool input JSON and stopReason is maxTokens', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: typo in the test description — MaxTokenError should be MaxTokensError (plural, matching the class in errors.ts). The assertion itself is correct; just the it(...) string.

const provider = new TestModelProvider(async function* () {
yield { type: 'modelMessageStartEvent', role: 'assistant' }
yield {
type: 'modelContentBlockStartEvent',
start: { type: 'toolUseStart', toolUseId: 'tool1', name: 'get_weather' },
start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' },
}
yield {
type: 'modelContentBlockDeltaEvent',
delta: { type: 'toolUseInputDelta', input: '{invalid json' },
delta: { type: 'toolUseInputDelta', input: '{"field": "value"' },
}
yield { type: 'modelContentBlockStopEvent' }
yield { type: 'modelMessageStopEvent', stopReason: 'maxTokens' }
})

const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })]

await expect(async () => await collectGenerator(provider.streamAggregated(messages))).rejects.toThrow(
MaxTokensError
)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Missing test coverage for the path where the stream ends without a modelMessageStopEvent while deferredToolInputParseError is set (i.e., the 'Stream ended without completing a message' error with the SyntaxError cause attached at line 477).

Suggestion: Add a test case where the generator yields a tool input delta + contentBlockStopEvent (triggering the parse error) but does NOT yield a modelMessageStopEvent. This exercises the new cause attachment on the "stream ended" error path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test added


it('surfaces SyntaxError as cause when tool input JSON is malformed and stopReason is not maxTokens', async () => {
const provider = new TestModelProvider(async function* () {
yield { type: 'modelMessageStartEvent', role: 'assistant' }
yield {
type: 'modelContentBlockStartEvent',
start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' },
}
yield {
type: 'modelContentBlockDeltaEvent',
delta: { type: 'toolUseInputDelta', input: '{invalid json' },
}
yield { type: 'modelContentBlockStopEvent' }
yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' }
})

const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })]

try {
await collectGenerator(provider.streamAggregated(messages))
expect.fail('Expected error to be thrown')
} catch (error) {
expect(error).toBeInstanceOf(ModelError)
expect(error).not.toBeInstanceOf(MaxTokensError)
expect((error as ModelError).message).toBe('unable to parse tool input JSON')
expect((error as ModelError).cause).toBeInstanceOf(SyntaxError)
}
})

it('attaches SyntaxError as cause when stream ends without a stop event after malformed tool input', async () => {
const provider = new TestModelProvider(async function* () {
yield { type: 'modelMessageStartEvent', role: 'assistant' }
yield {
type: 'modelContentBlockStartEvent',
start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' },
}
yield {
type: 'modelContentBlockDeltaEvent',
delta: { type: 'toolUseInputDelta', input: '{invalid json' },
}
yield { type: 'modelContentBlockStopEvent' }
})

const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })]

try {
await collectGenerator(provider.streamAggregated(messages))
expect.fail('Expected error to be thrown')
} catch (error) {
expect(error).toBeInstanceOf(ModelError)
expect(error).not.toBeInstanceOf(MaxTokensError)
expect((error as ModelError).message).toBe('Stream ended without completing a message')
expect((error as ModelError).cause).toBeInstanceOf(SyntaxError)
}
})
Expand Down
14 changes: 11 additions & 3 deletions strands-ts/src/models/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> {
let finalStopReason: StopReason | null = null
let metadata: ModelMetadataEvent | undefined = undefined
let redactionMessage: string | undefined = undefined
let deferredToolInputParseError: SyntaxError | undefined = undefined

for await (const event_data of this.stream(messages, options)) {
const event = this._convert_to_class_event(event_data)
Expand Down Expand Up @@ -425,8 +426,8 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> {
yield block
} catch (e: unknown) {
if (e instanceof SyntaxError) {
logger.error('unable to parse JSON string', e)
throw e
logger.error('unable to parse tool input JSON', e)
deferredToolInputParseError = e
}
}
break
Expand Down Expand Up @@ -471,7 +472,10 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> {

if (!stoppedMessage || !finalStopReason) {
// If we exit the loop without completing a message or stop reason, throw an error
throw new ModelError('Stream ended without completing a message')
throw new ModelError(
'Stream ended without completing a message',
deferredToolInputParseError ? { cause: deferredToolInputParseError } : undefined
)
}

// Attach metadata after redaction so it applies to the final message.
Expand All @@ -495,6 +499,10 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> {
)
}

if (deferredToolInputParseError !== undefined) {
throw new ModelError('unable to parse tool input JSON', { cause: deferredToolInputParseError })
}

// Return the final message with stop reason and optional metadata
const result: StreamAggregatedResult = {
message: stoppedMessage,
Expand Down